Compare commits

...
616 Commits
Author SHA1 Message Date
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 Baekanddjedi-knight 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 BaekandClassic298 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
Classic298andTim Baek 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
Classic298andClaude 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
Classic298andZureno 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
Classic298andwhyiug 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 BaekandJacob Leksan 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
Classic298andClaude Opus 4.8 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
Classic298andrexpository 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
Classic298andbrodmart 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
Classic298andbogdancherniy11-sudo 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
Classic298andbogdancherniy11-sudo 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
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
Classic298andlegobattman 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 BaekandVince Castillo, PhD 7801909d27 a11y
Co-Authored-By: Vince Castillo, PhD <154394560+professorcastillo@users.noreply.github.com>
2026-07-27 01:43:58 -04:00
Classic298andmaxntv 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, PhDandTim Baek 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
Classic298andbabakizo420 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 BaekandClassic298 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
Classic298andGrg0rry 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
G30andClaude Fable 5 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
Classic298andClaude 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 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
Classic298andGabrielGomesAL 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-lgtmandcrustopher-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
G30andClaude Opus 4.8 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
Classic298andmanus-use 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)
* 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
Classic298andClaude 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
Classic298andClaude Opus 4.8 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
Classic298andlegobattman 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
Classic298andClaude 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 BehetandTim Baek 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
G30andClaude Fable 5 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
Classic298andClaude 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)
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
Classic298andClaude 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-PerezandClaude Opus 4.8 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
andrep2222andandrep 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
Classic298andClaude 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 BaekandClassic298 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
Classic298andClaude 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
Classic298andClaude 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
Classic298andkomyunghan 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
Classic298andClaude 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
Classic298andClaude Fable 5 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
Classic298andClaude 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 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 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 2026-07-17 23:39:49 -04:00
Timothy Jaeryang Baek 1a32d92d08 refac 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 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 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 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 2026-07-13 12:06:17 -04:00
Timothy Jaeryang Baek 09d4cccb79 refac 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) 2026-07-10 13:33:46 -05:00
Classic298andClaude 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
Classic298andmanus-use 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
Classic298andclassic298 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 2026-07-09 16:20:19 -05:00
Timothy Jaeryang Baek 10724d057a refac 2026-07-02 12:38:22 -05:00
649 changed files with 85875 additions and 38140 deletions
+3
View File
@@ -16,6 +16,9 @@ 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 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='*'
+12
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 }}
+2
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:
+43
View File
@@ -0,0 +1,43 @@
name: Issue Labeler
on:
issues:
types: [opened]
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 title = issue.title ?? '';
const body = issue.body ?? '';
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
const bugLikeTitle = /^\s*\[?(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) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['bug']
});
}
+290
View File
@@ -5,6 +5,296 @@ 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.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
+11
View File
@@ -184,6 +184,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
+2 -4
View File
@@ -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.
+98 -41
View File
@@ -853,6 +853,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', '')
@@ -1176,6 +1183,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')
@@ -1664,7 +1672,13 @@ if default_prompt_suggestions == []:
DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions
MODEL_ORDER_LIST = []
try:
model_order_list = json.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', '{}'))
@@ -1806,6 +1820,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'
@@ -1840,6 +1857,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'
@@ -1926,10 +1947,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,
@@ -1983,10 +2006,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', '')
@@ -2124,33 +2157,41 @@ TASK_MODEL = os.getenv('TASK_MODEL', '')
TASK_MODEL_EXTERNAL = os.getenv('TASK_MODEL_EXTERNAL', '')
CONTEXT_COMPACTION_MODEL = os.getenv('CONTEXT_COMPACTION_MODEL', '')
ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', '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}}
@@ -2400,6 +2441,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'
@@ -2553,6 +2599,23 @@ if _oauth_authorize_params:
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:
@@ -2563,10 +2626,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 {}),
)
@@ -2584,10 +2644,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
@@ -2608,10 +2665,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
@@ -2624,30 +2678,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
@@ -2783,6 +2826,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,
@@ -2875,6 +2919,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,
@@ -3014,8 +3059,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,
@@ -3032,8 +3085,11 @@ DEFAULT_CONFIG = {
'auth.admin.email': ADMIN_EMAIL,
'task.model.default': TASK_MODEL,
'task.model.external': TASK_MODEL_EXTERNAL,
'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,
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,
@@ -3055,6 +3111,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,
+83 -6
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):
@@ -389,6 +395,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
@@ -565,12 +577,28 @@ try:
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'
@@ -594,6 +622,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.
@@ -788,6 +825,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
@@ -1034,10 +1083,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'
)
@@ -1091,15 +1164,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'
+81 -3
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.',
@@ -472,6 +483,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'
)
@@ -566,6 +587,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'
)
@@ -622,6 +648,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()
@@ -629,6 +661,12 @@ 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,
)
def get_event_catalog() -> list[dict[str, str]]:
@@ -1024,7 +1062,27 @@ 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)
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
@@ -1033,6 +1091,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
@@ -1081,7 +1145,7 @@ class EventFunctionSink:
schedule_event_function_dispatch(app, event, request)
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()]
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
async def publish_event(
@@ -1147,6 +1211,20 @@ async def publish_model_provider_request_failed(
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,
+12 -2
View File
@@ -20,7 +20,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
@@ -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 = []
@@ -144,7 +147,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)
@@ -203,6 +209,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
return params
# 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)
+2 -1
View File
@@ -370,6 +370,8 @@ 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_SQLALCHEMY_DATABASE_URL,
@@ -377,7 +379,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
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')
+349 -93
View File
@@ -83,6 +83,7 @@ from open_webui.env import (
ENABLE_COMPRESSION_MIDDLEWARE,
ENABLE_CUSTOM_MODEL_FALLBACK,
ENABLE_EASTER_EGGS,
ENABLE_PLUGINS,
EXTERNAL_PWA_MANIFEST_URL,
# OAuth Back-Channel Logout
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
@@ -153,6 +154,7 @@ from open_webui.routers import (
knowledge,
memories,
models,
notifications,
notes,
ollama,
openai,
@@ -218,7 +220,16 @@ from open_webui.utils.chat import (
from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler,
)
from open_webui.utils.chat_id import (
get_temporary_chat_session_id,
is_saved_chat_id,
is_temporary_chat_id,
)
from open_webui.utils.chat_variables import (
normalize_chat_variables,
)
from open_webui.utils.embeddings import generate_embeddings
from open_webui.utils.json_response import apply_orjson_http_json
from open_webui.utils.logger import start_logger
from open_webui.utils.middleware import (
background_tasks_handler,
@@ -226,6 +237,7 @@ from open_webui.utils.middleware import (
process_chat_payload,
process_chat_response,
)
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.models import (
check_model_access,
get_all_base_models,
@@ -247,7 +259,7 @@ from open_webui.utils.oauth import (
from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.redis import get_redis_client
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.utils.session_pool import get_session
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
from open_webui.utils.tools import set_terminal_servers, set_tool_servers
if SAFE_MODE:
@@ -258,6 +270,16 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
async def emit_chat_list_event(metadata: dict, chat_id: str):
if not is_saved_chat_id(chat_id):
return
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, metadata.get('user_id'))
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id, 'folder_id': folder_id}})
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
@@ -319,8 +341,9 @@ async def lifespan(app: FastAPI):
await migrate_legacy_webhook_config()
await publish_event(app, EVENTS.SYSTEM_STARTUP_STARTED, source='system')
license_task = None
if LICENSE_KEY:
get_license_data(app, LICENSE_KEY)
license_task = asyncio.create_task(asyncio.to_thread(get_license_data, app, LICENSE_KEY))
# Create admin account from env vars if specified and no users exist
if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD:
@@ -408,6 +431,14 @@ async def lifespan(app: FastAPI):
log.warning(f'Failed to initialize terminal servers at startup: {e}')
# Mark application as ready to accept traffic from a startup perspective.
if license_task:
try:
await asyncio.wait_for(asyncio.shield(license_task), timeout=2)
except asyncio.TimeoutError:
log.warning('License data retrieval is still pending; continuing startup without it')
except Exception as e:
log.warning(f'License data retrieval failed during startup: {e}')
app.state.startup_complete = True
await publish_event(app, EVENTS.SYSTEM_STARTUP_COMPLETED, source='system')
@@ -426,6 +457,10 @@ async def lifespan(app: FastAPI):
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system')
# Opt-in (ENABLE_ORJSON): orjson for request-body parsing and JSONResponse bodies;
# response_model routes keep FastAPI's Pydantic fast path either way.
apply_orjson_http_json()
app = FastAPI(
title='Open WebUI',
docs_url='/docs' if ENV == 'dev' else None,
@@ -697,6 +732,25 @@ app.state.speech_speaker_embeddings_dataset = None
app.state.MODELS = MODELS
# Add the middleware to the app
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
except ValueError as e:
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
audit_level = AuditLevel.NONE
# Added before CompressMiddleware so audit sits inside compression and
# captures response bodies before they are compressed (last added runs
# outermost).
if audit_level != AuditLevel.NONE:
app.add_middleware(
AuditLoggingMiddleware,
audit_level=audit_level,
excluded_paths=AUDIT_EXCLUDED_PATHS,
included_paths=AUDIT_INCLUDED_PATHS,
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
max_body_size=MAX_BODY_LOG_SIZE,
)
if ENABLE_COMPRESSION_MIDDLEWARE:
app.add_middleware(CompressMiddleware)
@@ -751,6 +805,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes'])
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
app.include_router(notifications.router, prefix='/api/v1/notifications', tags=['notifications'])
app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge'])
app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts'])
app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools'])
@@ -774,21 +829,6 @@ if ENABLE_SCIM:
app.include_router(scim.router, prefix='/api/v1/scim/v2', tags=['scim'])
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
except ValueError as e:
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
audit_level = AuditLevel.NONE
if audit_level != AuditLevel.NONE:
app.add_middleware(
AuditLoggingMiddleware,
audit_level=audit_level,
excluded_paths=AUDIT_EXCLUDED_PATHS,
included_paths=AUDIT_INCLUDED_PATHS,
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
max_body_size=MAX_BODY_LOG_SIZE,
)
##################################
#
# Chat Endpoints
@@ -801,12 +841,20 @@ if audit_level != AuditLevel.NONE:
async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)):
all_models = await get_all_models(request, refresh=refresh, user=user)
models = []
for model in all_models:
# Filter out filter pipelines
if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter':
continue
# Filter out filter pipelines
models = [
model for model in all_models if not ('pipeline' in model and model['pipeline'].get('type', None) == 'filter')
]
# Chat requests resolve models by ID from request.app.state.MODELS, where
# duplicate IDs collapse to the last model. Return the same effective list.
models = list({model['id']: model for model in models}.values())
# Access-filter first so the per-model payload work below only runs for
# models the caller can actually see.
models = await get_filtered_models(models, user)
for model in models:
# Remove profile image URL to reduce payload size
if model.get('info', {}).get('meta', {}).get('profile_image_url'):
model['info']['meta'].pop('profile_image_url', None)
@@ -820,13 +868,6 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
except Exception as e:
log.debug(f'Error processing model tags: {e}')
model['tags'] = []
pass
models.append(model)
# Chat requests resolve models by ID from request.app.state.MODELS, where
# duplicate IDs collapse to the last model. Return the same effective list.
models = list({model['id']: model for model in models}.values())
model_order_list = await Config.get('ui.model_order_list')
if model_order_list:
@@ -839,11 +880,10 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
)
)
models = await get_filtered_models(models, user)
log.debug(
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
)
if log.isEnabledFor(logging.DEBUG):
log.debug(
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
)
return {'data': models}
@@ -857,12 +897,6 @@ class ModelUnloadForm(BaseModel):
model: str
def strip_provider_model_prefix(model_id: str, prefix_id: str | None) -> str:
if prefix_id and model_id.startswith(f'{prefix_id}.'):
return model_id[len(f'{prefix_id}.') :]
return model_id
@app.post('/api/models/unload')
async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)):
"""
@@ -1004,6 +1038,17 @@ async def embeddings(request: Request, form_data: dict, user=Depends(get_verifie
return await generate_embeddings(request, form_data, user)
async def _set_direct_model(request: Request, model_item: dict, user) -> None:
model_meta = (model_item.get('info') or {}).get('meta') or {}
knowledge_items = model_meta.get('knowledge')
if knowledge_items:
from open_webui.utils.access_control.files import get_accessible_folder_files
model_meta['knowledge'] = await get_accessible_folder_files(knowledge_items, user)
request.state.direct = True
request.state.model = model_item
@app.post('/api/chat/completions')
@app.post('/api/v1/chat/completions') # Experimental: Compatibility with OpenAI API
async def chat_completion(
@@ -1031,14 +1076,12 @@ async def chat_completion(
# Check if user has access to the model
if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL):
try:
await check_model_access(user, model)
await check_model_access(user, model, model_info=model_info)
except Exception as e:
raise e
else:
model = model_item
request.state.direct = True
request.state.model = model
await _set_direct_model(request, model, user)
# Model params: global defaults as base, per-model overrides win
default_model_params = await Config.get('models.default_params', {}) or {}
@@ -1112,6 +1155,13 @@ async def chat_completion(
message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}]
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
chat_id = form_data.get('chat_id') or ''
chat_variables = form_data.pop('chat_variables', None)
if chat_variables is None:
existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
chat_variables = existing_chat.variables if existing_chat else {}
chat_variables = normalize_chat_variables(chat_variables)
# Drop tool_servers if caller lacks features.direct_tool_servers —
# mirrors the storage-side strip in user/settings/update.
@@ -1130,6 +1180,7 @@ async def chat_completion(
metadata = {
'user_id': user.id,
'user_agent': request.headers.get('user-agent', '') or '',
'internal': getattr(request.state, 'internal', False) is True,
'chat_id': form_data.pop('chat_id', None) or '',
'user_message': user_message,
'user_message_id': user_message.get('id') if user_message else None,
@@ -1142,6 +1193,7 @@ async def chat_completion(
'files': form_data.get('files', None),
'features': form_data.get('features', {}),
'variables': form_data.get('variables', {}),
'chat_variables': chat_variables,
'model': model,
'direct': model_item.get('direct', False),
'params': {
@@ -1205,9 +1257,7 @@ async def chat_completion(
detail=ERROR_MESSAGES.DEFAULT(),
)
if not chat_id.startswith('local:') and not chat_id.startswith(
'channel:'
): # temporary/channel chats are not stored
if is_saved_chat_id(chat_id):
if is_new_chat:
# Build the full history upfront with ALL assistant placeholders
user_message = metadata.get('user_message') or {}
@@ -1224,7 +1274,7 @@ async def chat_completion(
target_model_id = entry['model_id']
assistant_message_id = entry['message_id']
if assistant_message_id:
history_messages[assistant_message_id] = {
assistant_message = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
@@ -1234,6 +1284,11 @@ async def chat_completion(
'model': target_model_id,
'timestamp': int(time.time()),
}
# Preserve the side-by-side column index so duplicate
# models don't collapse into one another on reload.
if entry.get('modelIdx') is not None:
assistant_message['modelIdx'] = entry['modelIdx']
history_messages[assistant_message_id] = assistant_message
await Chats.insert_new_chat(
chat_id,
@@ -1256,6 +1311,7 @@ async def chat_completion(
'tags': [],
'timestamp': int(time.time() * 1000),
},
variables=chat_variables,
folder_id=metadata.get('folder_id'),
),
)
@@ -1266,6 +1322,7 @@ async def chat_completion(
subject_id=chat_id,
data={'title': 'New Chat'},
)
await emit_chat_list_event(metadata, chat_id)
if user_message_id:
await publish_event(
request,
@@ -1358,7 +1415,9 @@ async def chat_completion(
updated['files'] = chat_files
if selected_chat_models:
updated['models'] = selected_chat_models
await Chats.update_chat_by_id(chat_id, updated)
await Chats.update_chat_by_id(chat_id, updated, touch=False)
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
# Save user message to DB
if user_message and user_message.get('id'):
@@ -1367,6 +1426,7 @@ async def chat_completion(
user_message['id'],
user_message,
)
await emit_chat_list_event({**metadata, 'message_id': user_message['id']}, chat_id)
await publish_event(
request,
EVENTS.MESSAGE_CREATED,
@@ -1378,6 +1438,15 @@ async def chat_completion(
'content_preview': user_message.get('content', '')[:300],
},
)
if not getattr(request.state, 'internal', False) and not (user_message.get('meta') or {}).get(
'internal'
):
try:
from open_webui.utils.timers import cancel_timers_for_chat
await cancel_timers_for_chat(chat_id, 'chat.user_message', user.id)
except Exception:
log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id)
# Link grandparent → user message (childrenIds)
grandparent_id = user_message.get('parentId')
@@ -1432,19 +1501,24 @@ async def chat_completion(
target_model_id = entry['model_id']
assistant_message_id = entry['message_id']
if assistant_message_id:
assistant_message = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': target_model_id,
'timestamp': int(time.time()),
}
# Preserve the side-by-side column index so duplicate
# models don't collapse into one another on reload.
if entry.get('modelIdx') is not None:
assistant_message['modelIdx'] = entry['modelIdx']
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
assistant_message_id,
{
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': target_model_id,
'timestamp': int(time.time()),
},
assistant_message,
)
await publish_event(
request,
@@ -1513,9 +1587,7 @@ async def chat_completion(
if metadata.get('chat_id') and metadata.get('message_id'):
# Update the chat message with the error
try:
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get(
'chat_id', ''
).startswith('channel:'):
if is_saved_chat_id(metadata.get('chat_id')):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
@@ -1583,15 +1655,55 @@ async def chat_completion(
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
try:
await asyncio.shield(event_emitter({'type': 'chat:active', 'data': {'active': False}}))
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(
chat_id, user.id
)
await asyncio.shield(
event_emitter(
{
'type': 'chat:active',
'data': {'active': False, 'folder_id': folder_id},
}
)
)
except asyncio.CancelledError:
pass
except Exception:
pass
try:
chat_id = metadata.get('chat_id')
if (
chat_id
and getattr(request.state, 'internal', False) is not True
and not await has_active_tasks(request.app.state.redis, chat_id)
):
from open_webui.utils.subagents import process_pending_internal_messages
await process_pending_internal_messages(
request,
chat_id,
user.id,
{
'model_id': metadata.get('model_id') or form_data.get('model'),
'session_id': metadata.get('session_id'),
'tool_ids': metadata.get('tool_ids') or [],
'skill_ids': metadata.get('skill_ids') or [],
'system_prompt': metadata.get('system_prompt'),
'filter_ids': metadata.get('filter_ids') or [],
'terminal_id': metadata.get('terminal_id'),
'features': metadata.get('features') or {},
'variables': metadata.get('variables') or {},
},
)
except Exception:
log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id'))
# Fan out: one task per model
if metadata.get('session_id') and metadata.get('chat_id'):
task_ids = []
subagent_results = []
is_internal = getattr(request.state, 'internal', False) is True
chat_id = metadata['chat_id']
for idx, entry in enumerate(message_ids):
@@ -1604,6 +1716,7 @@ async def chat_completion(
per_model_metadata = {
**metadata,
'message_id': assistant_message_id,
'task_id': str(uuid4()),
}
# Per-model form_data: own model
@@ -1618,28 +1731,39 @@ async def chat_completion(
# Only the first model runs chat-level background tasks;
# subsequent models only run follow-ups.
process = process_chat(
request,
model_form_data,
user,
per_model_metadata,
resolved_model,
tasks
if idx == 0
else {
k: v for k, v in (tasks or {}).items() if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
}
or None,
)
if is_internal:
subagent_results.append(await process)
continue
task_id, _ = await create_task(
request.app.state.redis,
process_chat(
request,
model_form_data,
user,
per_model_metadata,
resolved_model,
tasks
if idx == 0
else {
k: v
for k, v in (tasks or {}).items()
if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
}
or None,
),
process,
id=chat_id,
task_id=per_model_metadata['task_id'],
)
per_model_metadata['task_id'] = task_id
task_ids.append(task_id)
if is_internal:
return {
'status': True,
'task_ids': [],
'chat_id': chat_id,
'results': subagent_results,
}
# Emit chat:active=true
if task_ids:
event_emitter = await get_event_emitter(
@@ -1647,7 +1771,8 @@ async def chat_completion(
update_db=False,
)
if event_emitter:
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, user.id)
await event_emitter({'type': 'chat:active', 'data': {'active': True, 'folder_id': folder_id}})
return {
'status': True,
@@ -1679,10 +1804,80 @@ app.state.CHAT_COMPLETION_HANDLER = chat_completion
from open_webui.utils.anthropic import (
convert_anthropic_to_openai_payload,
convert_openai_to_anthropic_response,
is_anthropic_messages_passthrough,
openai_stream_to_anthropic_stream,
)
@app.post('/api/message/count_tokens')
@app.post('/api/v1/messages/count_tokens') # Anthropic Messages token-count endpoint
async def count_message_tokens(
request: Request,
form_data: dict,
user=Depends(get_verified_user),
):
return {'input_tokens': await openai.count_anthropic_tokens(request, form_data, user)}
async def passthrough_anthropic_messages(request: Request, form_data: dict, user) -> Response | dict:
requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_token_count_target(
request, form_data, user
)
request_url = f'{url.rstrip("/")}/messages'
response = None
streaming = False
try:
session = await get_session()
response = await session.request(
method='POST',
url=request_url,
data=json.dumps(payload),
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=openai.AIOHTTP_CLIENT_TIMEOUT),
)
if 'text/event-stream' in response.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(response),
status_code=response.status,
headers=openai._clean_proxy_headers(response.headers),
)
try:
response_data = await response.json()
except Exception:
response_data = await response.text()
if response.status >= 400:
await openai.publish_model_provider_request_failed(
request,
actor=user,
provider='openai-compatible',
base_url=url,
api_key=key,
status=response.status,
requested_model=requested_model,
upstream_error=response_data,
)
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=response.status, content=response_data)
return Response(status_code=response.status, content=response_data)
return response_data
except HTTPException:
raise
except Exception:
log.exception('Failed to passthrough Anthropic Messages request for model %s', requested_model)
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
finally:
if not streaming:
await cleanup_response(response)
@app.post('/api/message')
@app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint
async def generate_messages(
@@ -1703,10 +1898,40 @@ async def generate_messages(
Authentication: Supports both standard Authorization header and
Anthropic's x-api-key header (via middleware translation).
"""
# Convert Anthropic payload to OpenAI format
requested_model = form_data.get('model', '')
input_tokens = None
try:
input_tokens = await openai.count_anthropic_tokens(request, form_data, user)
except Exception:
# Counting must not turn a compatible generation request into an outage.
log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True)
openai_payload = convert_anthropic_to_openai_payload(form_data)
model_id = requested_model
model_info = await Models.get_model_by_id(model_id)
if model_info and model_info.base_model_id:
model_id = model_info.base_model_id
passthrough_params = []
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await openai.get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
model = models.get(model_id)
if model:
url, _, api_config = await openai.get_openai_connection(model['urlIdx'])
if is_anthropic_messages_passthrough(url, api_config):
return await passthrough_anthropic_messages(request, form_data, user)
passthrough_params = api_config.get('passthrough_params') or []
# Convert Anthropic payload to OpenAI format
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
model_meta = model_info.meta.model_dump() if model_info and model_info.meta else {}
if (model_meta.get('capabilities') or {}).get('usage') is True:
if openai_payload.get('stream'):
stream_options = openai_payload.get('stream_options')
if not isinstance(stream_options, dict):
stream_options = {}
openai_payload['stream_options'] = {**stream_options, 'include_usage': True}
# Route through the existing chat_completion handler
response = await chat_completion(request, openai_payload, user)
@@ -1715,7 +1940,7 @@ async def generate_messages(
if isinstance(response, StreamingResponse):
# Streaming response: wrap the generator to convert SSE format
return StreamingResponse(
openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model),
openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model, input_tokens=input_tokens),
media_type='text/event-stream',
headers={
'Cache-Control': 'no-cache',
@@ -1723,22 +1948,42 @@ async def generate_messages(
},
)
elif isinstance(response, dict):
return convert_openai_to_anthropic_response(response, model=requested_model)
return convert_openai_to_anthropic_response(response, model=requested_model, input_tokens=input_tokens)
else:
# Passthrough for error responses (JSONResponse, PlainTextResponse, etc.)
return response
async def verify_chat_ownership(chat_id: str | None, user) -> None:
"""Temporary chats are per-socket and unsaved, so they have no owner to check."""
if not chat_id or is_temporary_chat_id(chat_id):
return
# Channel messages need the membership and write-access gate that only /api/chat/completions has.
if chat_id.startswith('channel:'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Channel chats are not supported on this endpoint',
)
if user.role != 'admin' and not await Chats.is_chat_owner(chat_id, user.id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
)
@app.post('/api/chat/completed')
async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)):
"""Deprecated: outlet filters now run inline during chat completion.
Kept for backward compatibility with external integrations."""
await verify_chat_ownership(form_data.get('chat_id'), user)
try:
model_item = form_data.pop('model_item', {})
if model_item.get('direct', False):
request.state.direct = True
request.state.model = model_item
await _set_direct_model(request, model_item, user)
return await chat_completed_handler(request, form_data, user)
except Exception as e:
@@ -1750,12 +1995,13 @@ async def chat_completed(request: Request, form_data: dict, user=Depends(get_ver
@app.post('/api/chat/actions/{action_id}')
async def chat_action(request: Request, action_id: str, form_data: dict, user=Depends(get_verified_user)):
await verify_chat_ownership(form_data.get('chat_id'), user)
try:
model_item = form_data.pop('model_item', {})
if model_item.get('direct', False):
request.state.direct = True
request.state.model = model_item
await _set_direct_model(request, model_item, user)
return await chat_action_handler(request, action_id, form_data, user)
except Exception as e:
@@ -1781,8 +2027,8 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
@app.get('/api/tasks/chat/{chat_id:path}')
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
socket_id = chat_id[len('local:') :]
socket_id = get_temporary_chat_session_id(chat_id)
if socket_id:
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
return {'task_ids': []}
@@ -1799,8 +2045,8 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
@app.post('/api/tasks/chat/{chat_id:path}/stop')
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
socket_id = chat_id[len('local:') :]
socket_id = get_temporary_chat_session_id(chat_id)
if socket_id:
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@@ -1852,6 +2098,7 @@ async def get_app_config(request: Request):
license_metadata = getattr(app.state, 'LICENSE_METADATA', None)
user_count = await Users.get_num_users() if license_metadata else None
config = await Config.get_many(
'oauth.enable',
'oauth.auto_redirect',
'ldap.enable',
'ui.enable_signup',
@@ -1865,6 +2112,7 @@ async def get_app_config(request: Request):
'calendar.enable',
'automations.enable',
'notes.enable',
'chat.context_compaction.enable',
'web.search.enable',
'web.search.confirmation.enable',
'web.search.confirmation.content',
@@ -1905,7 +2153,13 @@ async def get_app_config(request: Request):
'version': VERSION,
'default_locale': str(DEFAULT_LOCALE),
'oauth': {
'providers': {name: config.get('name', name) for name, config in OAUTH_PROVIDERS.items()},
# Hide providers (and thus the login buttons / auto-redirect) when OAuth
# is disabled, without clearing the admin's provider configuration.
'providers': (
{name: provider.get('name', name) for name, provider in OAUTH_PROVIDERS.items()}
if config.get('oauth.enable', True)
else {}
),
'auto_redirect': config.get('oauth.auto_redirect'),
},
'features': {
@@ -1927,12 +2181,14 @@ async def get_app_config(request: Request):
'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
'enable_easter_eggs': ENABLE_EASTER_EGGS,
'enable_direct_connections': config.get('direct.enable'),
'enable_plugins': ENABLE_PLUGINS,
'enable_folders': config.get('folders.enable'),
'folder_max_file_count': config.get('folders.max_file_count'),
'enable_channels': config.get('channels.enable'),
'enable_calendar': config.get('calendar.enable'),
'enable_automations': config.get('automations.enable'),
'enable_notes': config.get('notes.enable'),
'enable_context_compaction': config.get('chat.context_compaction.enable'),
'enable_web_search': config.get('web.search.enable'),
'enable_web_search_confirmation': config.get('web.search.confirmation.enable'),
'web_search_confirmation_content': config.get('web.search.confirmation.content'),
+2
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
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
+73 -8
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,
+15 -12
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(
+30 -5
View File
@@ -21,6 +21,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 +32,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):
@@ -72,6 +76,7 @@ class AutomationModel(BaseModel):
id: str
user_id: str
folder_id: Optional[str] = None
name: str
data: dict
meta: Optional[dict] = None
@@ -96,6 +101,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 +135,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 +146,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 +171,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,6 +179,9 @@ class AutomationTable:
async with get_async_db_context(db) as db:
stmt = select(Automation).filter_by(user_id=user_id)
if folder_id is not None:
stmt = stmt.filter(Automation.folder_id == (folder_id or None))
if query:
search = f'%{query}%'
# Search in name and prompt inside JSON data
@@ -217,6 +227,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 +235,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 +268,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 +350,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]:
+23 -10
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()]
@@ -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,
+4 -5
View File
@@ -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,
@@ -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:
+295 -2
View File
@@ -1,7 +1,10 @@
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 +50,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 +86,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 +148,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)
@@ -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
@@ -192,6 +242,8 @@ class ChatMessageTable:
existing.sources = data.get('sources')
if 'embeds' in data:
existing.embeds = data.get('embeds')
if 'meta' in data:
existing.meta = data.get('meta')
if 'done' in data:
existing.done = data.get('done', True)
if 'status_history' in data or 'statusHistory' in data:
@@ -207,7 +259,6 @@ class ChatMessageTable:
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)
else:
# Insert new
@@ -225,6 +276,7 @@ class ChatMessageTable:
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'),
@@ -235,7 +287,6 @@ class ChatMessageTable:
)
db.add(message)
await db.commit()
await db.refresh(message)
return ChatMessageModel.model_validate(message)
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
@@ -243,6 +294,21 @@ class ChatMessageTable:
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 +650,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
+6
View File
@@ -46,8 +46,10 @@ API_CONFIG_FIELDS = (
'auth_type',
'headers',
'azure',
'api_type',
'api_version',
'extra_params',
'passthrough_params',
)
@@ -247,6 +249,10 @@ 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))
-1
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:
-1
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:
+1
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
+24 -13
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:
+56 -21
View File
@@ -36,6 +36,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 +152,7 @@ class KnowledgeDirectoryForm(BaseModel):
####################
class KnowledgeUserModel(KnowledgeModel):
user: Optional[UserResponse] = None
file_count: int | None = None
class KnowledgeResponse(KnowledgeModel):
@@ -191,11 +195,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 +312,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 +336,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 +358,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),
}
)
)
@@ -469,20 +492,16 @@ class KnowledgeTable:
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
# One grants query for all non-owned knowledge bases instead of one each
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='knowledge',
resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id],
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids]
async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
try:
@@ -665,9 +684,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 []
+2 -3
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
+73 -29
View File
@@ -3,7 +3,8 @@ from __future__ import annotations
import json
import logging
import time
from typing import Optional
from copy import deepcopy
from typing import Any, Optional
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
@@ -22,6 +23,38 @@ log = logging.getLogger(__name__)
_warned_profile_urls: set[str] = set()
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 +70,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,6 +90,11 @@ 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):
@@ -152,11 +191,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 +220,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:
@@ -256,26 +302,26 @@ class ModelsTable:
]
async def get_models_by_user_id(
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
self,
user_id: str,
permission: str = 'write',
db: AsyncSession | None = None,
user_group_ids: set[str] | 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}
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)}
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
# One grants query for all non-owned models instead of one per model
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='model',
resource_ids=[model.id for model in models if model.user_id != user_id],
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
return [model for model in models if model.user_id == user_id or model.id in accessible_ids]
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
@@ -483,7 +529,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 +555,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
+10 -6
View File
@@ -106,11 +106,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(
@@ -308,9 +309,12 @@ class NoteTable:
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)
@@ -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
@@ -70,7 +70,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(
+4 -7
View File
@@ -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:
@@ -637,7 +635,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:
+26 -9
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)
@@ -259,7 +258,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 +325,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 +342,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:
-1
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)
+31 -28
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)
@@ -171,11 +171,18 @@ class ToolsTable:
async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> 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
result = await db.execute(stmt)
all_tools = result.scalars().all()
# Skip Tool.content (plugin source, potentially large) via a
# column select; Row attributes satisfy from_attributes.
result = await db.execute(
select(
Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at
).order_by(Tool.updated_at.desc())
)
all_tools = result.all()
else:
result = await db.execute(select(Tool).order_by(Tool.updated_at.desc()))
all_tools = result.scalars().all()
user_ids = list(set(tool.user_id for tool in all_tools))
tool_ids = [tool.id for tool in all_tools]
@@ -214,20 +221,16 @@ class ToolsTable:
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
# One grants query for all non-owned tools instead of one per tool
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='tool',
resource_ids=[tool.id for tool in tools if tool.user_id != user_id],
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids]
async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
try:
@@ -301,8 +304,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
+13 -21
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,
@@ -69,6 +69,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 +106,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 +128,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 +309,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
@@ -566,13 +572,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 +588,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 +600,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 +619,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)
@@ -643,7 +639,6 @@ class UsersTable:
oauth[provider] = {'sub': sub}
user.oauth = oauth
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_scim_by_id(
@@ -662,7 +657,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 +667,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 +681,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 +727,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 +754,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
+4 -3
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
@@ -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)
+43 -7
View File
@@ -11,7 +11,6 @@ from langchain_community.document_loaders import (
BSHTMLLoader,
CSVLoader,
Docx2txtLoader,
OutlookMessageLoader,
PyPDFLoader,
TextLoader,
YoutubeLoader,
@@ -27,7 +26,8 @@ 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
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@@ -206,6 +206,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 +249,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 +268,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 +314,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.
@@ -422,6 +440,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,
@@ -554,8 +573,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'),
@@ -654,7 +679,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
@@ -5,12 +5,13 @@ 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 AIOHTTP_CLIENT_SESSION_SSL, 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__)
@@ -39,6 +40,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 +52,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.')
@@ -63,6 +67,7 @@ class MistralLoader:
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 +87,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:
"""
@@ -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."""
@@ -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,
+52 -11
View File
@@ -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.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 include_user_info_headers
from open_webui.utils.misc import get_message_list
from open_webui.utils.misc import get_content_from_message, get_message_list
log = logging.getLogger(__name__)
@@ -71,6 +73,20 @@ LOADER_CONFIG_KEYS = {
'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',
@@ -126,6 +142,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,
)
@@ -615,7 +632,7 @@ 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():
combined[doc_hash] = (distance, document, metadata)
@@ -1250,7 +1267,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 +1302,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 +1335,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
@@ -1390,7 +1424,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 +1466,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 +1497,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 +1517,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 +1540,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)
@@ -1570,7 +1611,7 @@ async def get_sources_from_items(
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}')
@@ -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.
@@ -25,7 +25,7 @@ from open_webui.retrieval.vector.main import (
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
from pymilvus import Collection, DataType, FieldSchema, connections
from pymilvus import DataType
from pymilvus import MilvusClient as Client
from pymilvus.exceptions import MilvusException
@@ -202,8 +202,6 @@ class MilvusClient(VectorDBBase):
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 +216,16 @@ 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}"
)
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',
@@ -23,14 +23,8 @@ from open_webui.retrieval.vector.main import (
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 +64,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 +105,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)
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(f'Created shared collection: {mt_collection_name}')
return collection
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 +174,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 +196,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 +216,18 @@ 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(
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=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=['id', 'text', 'metadata'],
)
@@ -235,10 +235,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 +255,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 +269,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 +305,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,
)
@@ -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:
+19 -7
View File
@@ -1,10 +1,11 @@
from __future__ import annotations
import ipaddress
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 get_allow_block_lists, is_host_allowed
from pydantic import BaseModel
@@ -12,6 +13,16 @@ def get_filtered_results(results, filter_list):
if not filter_list:
return results
allow_list, block_list = get_allow_block_lists(filter_list)
resolve_ips = False
for entry in allow_list + block_list:
try:
ipaddress.ip_address(entry)
except ValueError:
continue
resolve_ips = True
break
filtered_results = []
for result in results:
@@ -25,12 +36,13 @@ def get_filtered_results(results, filter_list):
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)
@@ -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]
]
+22 -1
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
@@ -17,6 +20,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 +64,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()
+173 -121
View File
@@ -3,9 +3,10 @@ import ipaddress
import logging
import socket
import ssl
import time
import urllib.parse
import urllib.request
from datetime import datetime, time, timedelta
from datetime import datetime, timedelta
from typing import (
Any,
AsyncIterator,
@@ -74,6 +75,34 @@ def resolve_hostname(hostname):
return ipv4_addresses, ipv6_addresses
def _is_global_addr(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
if not addr.is_global:
return False
if not isinstance(addr, ipaddress.IPv6Address):
return True
embedded = []
if addr.ipv4_mapped:
embedded.append(addr.ipv4_mapped)
if addr.sixtofour:
embedded.append(addr.sixtofour)
if addr.teredo:
embedded.extend(addr.teredo)
b = addr.packed
if b[:12] == b'\x00' * 12:
embedded.append(ipaddress.IPv4Address(b[12:]))
elif b[:12] == b'\x00\x64\xff\x9b' + b'\x00' * 8:
embedded.append(ipaddress.IPv4Address(b[12:]))
elif b[:6] == b'\x00\x64\xff\x9b\x00\x01':
if b[8] != 0:
return False
embedded.append(ipaddress.IPv4Address(bytes((b[6], b[7], b[9], b[10]))))
return all(ip.is_global for ip in embedded)
def validate_url(url: Union[str, Sequence[str]]):
if isinstance(url, str):
if isinstance(validators.url(url), validators.ValidationError):
@@ -110,8 +139,7 @@ def validate_url(url: Union[str, Sequence[str]]):
# Check if any of the resolved addresses are private
# DNS rebinding is mitigated at the connection layer; see _SSRFSafeResolver / _SSRFSafeAdapter
for ip in ipv4_addresses + ipv6_addresses:
addr = ipaddress.ip_address(ip)
if not addr.is_global:
if not _is_global_addr(ip):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
return True
elif isinstance(url, Sequence):
@@ -146,7 +174,7 @@ def _ssrf_safe_new_conn(self):
raise OSError(f'getaddrinfo for {host!r} returned empty list')
if not ENABLE_LOCAL_WEB_FETCH:
for _, _, _, _, sa in infos:
if not ipaddress.ip_address(sa[0]).is_global:
if not _is_global_addr(sa[0]):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
err = None
for fam, typ, proto, _, sa in infos:
@@ -158,6 +186,11 @@ def _ssrf_safe_new_conn(self):
if getattr(self, 'source_address', None):
sock.bind(self.source_address)
for opt in getattr(self, 'socket_options', None) or ():
if len(opt) == 4 and isinstance(opt[3], str):
# urllib3-future per-protocol form: (level, optname, value, "tcp"/"udp")
if opt[3].lower() == 'tcp':
sock.setsockopt(*opt[:3])
continue
sock.setsockopt(*opt)
sock.connect(sa)
return sock
@@ -202,7 +235,7 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver):
results = await super().resolve(host, port, family)
if not ENABLE_LOCAL_WEB_FETCH:
for entry in results:
if not ipaddress.ip_address(entry['host']).is_global:
if not _is_global_addr(entry['host']):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
return results
@@ -600,57 +633,63 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
def _intercept_navigation_sync(self, route, request=None):
req = request or route.request
if req.resource_type != 'document':
route.continue_()
return
try:
validate_url(req.url)
resp = route.fetch(max_redirects=0)
if 300 <= resp.status < 400:
for _ in range(20):
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
route.abort()
return
location = resp.headers.get('location')
if not location:
break
url = urllib.parse.urljoin(resp.url, location)
validate_url(url)
resp = route.fetch(url=url, max_redirects=0)
if not 300 <= resp.status < 400:
break
else:
route.abort()
return
except Exception:
route.abort()
return
if AIOHTTP_CLIENT_ALLOW_REDIRECTS:
resp = route.fetch()
else:
try:
resp = route.fetch(max_redirects=0)
except TypeError:
route.abort()
return
if 300 <= resp.status < 400:
route.abort()
return
route.fulfill(response=resp)
async def _intercept_navigation(self, route, request=None):
req = request or route.request
if req.resource_type != 'document':
await route.continue_()
return
try:
await run_in_threadpool(validate_url, req.url)
resp = await route.fetch(max_redirects=0)
if 300 <= resp.status < 400:
for _ in range(20):
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
await route.abort()
return
location = resp.headers.get('location')
if not location:
break
url = urllib.parse.urljoin(resp.url, location)
await run_in_threadpool(validate_url, url)
resp = await route.fetch(url=url, max_redirects=0)
if not 300 <= resp.status < 400:
break
else:
await route.abort()
return
except Exception:
await route.abort()
return
if AIOHTTP_CLIENT_ALLOW_REDIRECTS:
resp = await route.fetch()
else:
try:
resp = await route.fetch(max_redirects=0)
except TypeError:
await route.abort()
return
if 300 <= resp.status < 400:
await route.abort()
return
await route.fulfill(response=resp)
def lazy_load(self) -> Iterator[Document]:
@@ -664,24 +703,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
else:
browser = p.chromium.launch(headless=self.headless, proxy=self.proxy)
for url in self.urls:
try:
self._safe_process_url_sync(url)
page = browser.new_page()
page.route('**/*', self._intercept_navigation_sync)
response = page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f'page.goto() returned None for url {url}')
with browser:
for url in self.urls:
try:
self._safe_process_url_sync(url)
with browser.new_page(service_workers='block') as page:
page.route('**/*', self._intercept_navigation_sync)
page.route_web_socket('**/*', lambda ws_route: ws_route.close())
response = page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f'page.goto() returned None for url {url}')
text = self.evaluator.evaluate(page, browser, response)
metadata = {'source': url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error loading {url}: {e}')
continue
raise e
browser.close()
text = self.evaluator.evaluate(page, browser, response)
metadata = {'source': url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error loading {url}: {e}')
continue
raise e
async def alazy_load(self) -> AsyncIterator[Document]:
"""Safely load URLs asynchronously with support for remote browser."""
@@ -694,24 +734,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
else:
browser = await p.chromium.launch(headless=self.headless, proxy=self.proxy)
for url in self.urls:
try:
await self._safe_process_url(url)
page = await browser.new_page()
await page.route('**/*', self._intercept_navigation)
response = await page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f'page.goto() returned None for url {url}')
async with browser:
for url in self.urls:
try:
await self._safe_process_url(url)
async with await browser.new_page(service_workers='block') as page:
await page.route('**/*', self._intercept_navigation)
await page.route_web_socket('**/*', lambda ws_route: ws_route.close())
response = await page.goto(url, timeout=self.playwright_timeout)
if response is None:
raise ValueError(f'page.goto() returned None for url {url}')
text = await self.evaluator.evaluate_async(page, browser, response)
metadata = {'source': url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error loading {url}: {e}')
continue
raise e
await browser.close()
text = await self.evaluator.evaluate_async(page, browser, response)
metadata = {'source': url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(f'Error loading {url}: {e}')
continue
raise e
class SafeWebBaseLoader(WebBaseLoader):
@@ -723,6 +764,8 @@ class SafeWebBaseLoader(WebBaseLoader):
trust_env (bool, optional): set to True if using proxy to make web requests, for example
using http(s)_proxy environment variables. Defaults to False.
"""
# lxml parses scraped pages far faster than the html.parser default
kwargs.setdefault('default_parser', 'lxml')
super().__init__(*args, **kwargs)
self.trust_env = trust_env
@@ -785,20 +828,13 @@ class SafeWebBaseLoader(WebBaseLoader):
final_results = []
for i, result in enumerate(results):
url = urls[i]
if parser is None:
if url.endswith('.xml'):
parser = 'xml'
else:
parser = self.default_parser
self._check_parser(parser)
final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs))
url_parser = parser
if url_parser is None:
url_parser = 'xml' if url.endswith('.xml') else self.default_parser
self._check_parser(url_parser)
final_results.append(BeautifulSoup(result, url_parser, **self.bs_kwargs))
return final_results
async def ascrape_all(self, urls: List[str], parser: Union[str, None] = None) -> List[Any]:
"""Async fetch all urls, then return soups for all results."""
results = await self.fetch_all(urls)
return self._unpack_fetch_results(results, urls, parser=parser)
def lazy_load(self) -> Iterator[Document]:
"""Lazy load text from the url(s) in web_path with error handling."""
for path in self.web_paths:
@@ -814,19 +850,20 @@ class SafeWebBaseLoader(WebBaseLoader):
# Log the error and continue with the next URL
log.exception(f'Error loading {path}: {e}')
def _document_from_html(self, html: str, url: str) -> Document:
"""Build one Document."""
soup = self._unpack_fetch_results([html], [url])[0]
return Document(
page_content=soup.get_text(**self.bs_get_text_kwargs),
metadata=extract_metadata(soup, url),
)
async def alazy_load(self) -> AsyncIterator[Document]:
"""Async lazy load text from the url(s) in web_path."""
results = await self.ascrape_all(self.web_paths)
for path, soup in zip(self.web_paths, results):
text = soup.get_text(**self.bs_get_text_kwargs)
metadata = {'source': path}
if title := soup.find('title'):
metadata['title'] = title.get_text()
if description := soup.find('meta', attrs={'name': 'description'}):
metadata['description'] = description.get('content', 'No description found.')
if html := soup.find('html'):
metadata['language'] = html.get('lang', 'No language found.')
yield Document(page_content=text, metadata=metadata)
results = await self.fetch_all(self.web_paths)
for path, html in zip(self.web_paths, results):
# parsing a large page costs hundreds of ms, keep it off the event loop
yield await asyncio.to_thread(self._document_from_html, html, path)
async def aload(self) -> list[Document]:
"""Load data into Document objects."""
@@ -838,6 +875,7 @@ def get_web_loader(
verify_ssl: bool = True,
requests_per_second: int = 2,
trust_env: bool = False,
loader_config: Optional[dict] = None,
):
# Check if the URLs are valid
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
@@ -846,6 +884,16 @@ def get_web_loader(
log.warning(f'All provided URLs were blocked or invalid: {urls}')
raise ValueError(ERROR_MESSAGES.INVALID_URL)
loader_config = loader_config or {}
def cfg(key, env_value):
# Admin-saved DB value wins; env constant covers keys never saved.
value = loader_config.get(key)
return env_value if value is None else value
engine = cfg('web_loader_engine', WEB_LOADER_ENGINE)
web_loader_timeout = cfg('web_loader_timeout', WEB_LOADER_TIMEOUT)
web_loader_args = {
'web_paths': safe_urls,
'verify_ssl': verify_ssl,
@@ -854,13 +902,15 @@ def get_web_loader(
'trust_env': trust_env,
}
if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web':
WebLoaderClass = None
if engine == '' or engine == 'safe_web':
WebLoaderClass = SafeWebBaseLoader
request_kwargs = {}
if WEB_LOADER_TIMEOUT:
if web_loader_timeout:
try:
timeout_value = float(WEB_LOADER_TIMEOUT)
timeout_value = float(web_loader_timeout)
except ValueError:
timeout_value = None
@@ -870,42 +920,44 @@ def get_web_loader(
if request_kwargs:
web_loader_args['requests_kwargs'] = request_kwargs
if WEB_LOADER_ENGINE == 'playwright':
if engine == 'playwright':
WebLoaderClass = SafePlaywrightURLLoader
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT
if PLAYWRIGHT_WS_URL:
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL
web_loader_args['playwright_timeout'] = cfg('playwright_timeout', PLAYWRIGHT_TIMEOUT)
playwright_ws_url = cfg('playwright_ws_url', PLAYWRIGHT_WS_URL)
if playwright_ws_url:
web_loader_args['playwright_ws_url'] = playwright_ws_url
if WEB_LOADER_ENGINE == 'firecrawl':
if engine == 'firecrawl':
WebLoaderClass = SafeFireCrawlLoader
web_loader_args['api_key'] = FIRECRAWL_API_KEY
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL
if FIRECRAWL_TIMEOUT:
web_loader_args['api_key'] = cfg('firecrawl_api_key', FIRECRAWL_API_KEY)
web_loader_args['api_url'] = cfg('firecrawl_api_url', FIRECRAWL_API_BASE_URL)
firecrawl_timeout = cfg('firecrawl_timeout', FIRECRAWL_TIMEOUT)
if firecrawl_timeout:
try:
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT)
web_loader_args['timeout'] = int(firecrawl_timeout)
except ValueError:
pass
if WEB_LOADER_ENGINE == 'tavily':
if engine == 'tavily':
WebLoaderClass = SafeTavilyLoader
web_loader_args['api_key'] = TAVILY_API_KEY
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH
web_loader_args['api_key'] = cfg('tavily_api_key', TAVILY_API_KEY)
web_loader_args['extract_depth'] = cfg('tavily_extract_depth', TAVILY_EXTRACT_DEPTH)
if WEB_LOADER_ENGINE == 'microsoft_web_iq':
if engine == 'microsoft_web_iq':
WebLoaderClass = SafeMicrosoftWebIQLoader
web_loader_args['api_base_url'] = MICROSOFT_WEB_IQ_API_BASE_URL
web_loader_args['api_key'] = MICROSOFT_WEB_IQ_API_KEY
web_loader_args['language'] = MICROSOFT_WEB_IQ_LANGUAGE
if WEB_LOADER_TIMEOUT:
web_loader_args['api_base_url'] = cfg('microsoft_web_iq_api_base_url', MICROSOFT_WEB_IQ_API_BASE_URL)
web_loader_args['api_key'] = cfg('microsoft_web_iq_api_key', MICROSOFT_WEB_IQ_API_KEY)
web_loader_args['language'] = cfg('microsoft_web_iq_language', MICROSOFT_WEB_IQ_LANGUAGE)
if web_loader_timeout:
try:
web_loader_args['timeout'] = int(WEB_LOADER_TIMEOUT)
web_loader_args['timeout'] = int(web_loader_timeout)
except ValueError:
pass
if WEB_LOADER_ENGINE == 'external':
if engine == 'external':
WebLoaderClass = ExternalWebLoader
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY
web_loader_args['external_url'] = cfg('external_web_loader_url', EXTERNAL_WEB_LOADER_URL)
web_loader_args['external_api_key'] = cfg('external_web_loader_api_key', EXTERNAL_WEB_LOADER_API_KEY)
if WebLoaderClass:
web_loader = WebLoaderClass(**web_loader_args)
@@ -919,6 +971,6 @@ def get_web_loader(
return web_loader
else:
raise ValueError(
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. '
f'Invalid WEB_LOADER_ENGINE: {engine}. '
"Please set it to 'safe_web', 'playwright', 'firecrawl', 'tavily', 'external', or 'microsoft_web_iq'."
)
+40 -31
View File
@@ -49,6 +49,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
BYPASS_PYDUB_PREPROCESSING,
DEVICE_TYPE,
ENABLE_FORWARD_USER_INFO_HEADERS,
@@ -678,15 +679,19 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
for key, value in payload.items():
form_data.add_field(key, str(value))
with open(file_path, 'rb') as audio_file:
form_data.add_field('file', audio_file, filename=filename)
async def audio_chunks():
async with aiofiles.open(file_path, 'rb') as audio_file:
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
yield chunk
r = await session.post(
url=f'{api_base_url}/audio/transcriptions',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
form_data.add_field('file', audio_chunks(), filename=filename)
r = await session.post(
url=f'{api_base_url}/audio/transcriptions',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
if r.status == 200:
break
@@ -823,13 +828,18 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
base_url or f'https://{region}.api.cognitive.microsoft.com'
) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15'
form_data = aiohttp.FormData()
form_data.add_field('definition', definition)
form_data.add_field('audio', open(file_path, 'rb'), filename=filename)
r = None
try:
session = await get_session()
form_data = aiohttp.FormData()
form_data.add_field('definition', definition)
async def audio_chunks():
async with aiofiles.open(file_path, 'rb') as audio_file:
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
yield chunk
form_data.add_field('audio', audio_chunks(), filename=filename)
r = await session.post(
url=endpoint,
data=form_data,
@@ -1002,7 +1012,12 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
if language:
form_data.add_field('language', language)
form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type)
async def audio_chunks():
async with aiofiles.open(file_path, 'rb') as audio_file:
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
yield chunk
form_data.add_field('file', audio_chunks(), filename=filename, content_type=mime_type)
r = await session.post(
url=f'{api_base_url}/audio/transcriptions',
@@ -1076,25 +1091,23 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict]
detail=ERROR_MESSAGES.DEFAULT(e, 'Error processing audio file'),
)
results = []
try:
tasks = [transcription_handler(request, chunk_path, metadata, user) for chunk_path in chunk_paths]
for coro in asyncio.as_completed(tasks):
try:
results.append(await coro)
except HTTPException:
raise
except Exception as transcribe_exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Error transcribing chunk: {transcribe_exc}',
)
# gather keeps results in chunk order, unlike as_completed
results = await asyncio.gather(*tasks)
except HTTPException:
raise
except Exception as transcribe_exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Error transcribing chunk: {transcribe_exc}',
)
finally:
# Clean up only the temporary chunks, never the original file
for chunk_path in chunk_paths:
if chunk_path != file_path and os.path.isfile(chunk_path):
try:
os.remove(chunk_path)
await asyncio.to_thread(os.remove, chunk_path)
except Exception:
pass
@@ -1208,12 +1221,8 @@ async def transcription(
if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)):
raise ValueError('Invalid file path detected')
def _write_upload():
with open(file_path, 'wb') as f:
f.write(contents)
# Audio uploads can be large; write to disk off the event loop.
await asyncio.to_thread(_write_upload)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(contents)
try:
metadata = None
+150 -19
View File
@@ -9,11 +9,12 @@ import urllib
import uuid
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
from aiohttp import ClientSession
from aiohttp import BasicAuth, ClientSession
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, Response
from ldap3 import NONE, Connection, Server, Tls
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import parse_dn
from open_webui.config import (
ENABLE_PASSWORD_AUTH,
OAUTH_PROVIDERS,
@@ -24,6 +25,9 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
ENABLE_INITIAL_ADMIN_SIGNUP,
ENABLE_OAUTH_TOKEN_EXCHANGE,
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS,
WEBUI_AUTH,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
@@ -74,6 +78,7 @@ from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.rate_limit import RateLimiter
from open_webui.utils.redis import get_redis_client
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
@@ -83,6 +88,18 @@ log = logging.getLogger(__name__)
# Forgive us our failed attempts, as we forgive those
# who exceed their allotted rate against this gate.
signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3)
# Best-effort throttle only: there is no caller identity before the provider answers,
# and deployments may derive request.client from proxy headers.
token_exchange_rate_limiter = (
RateLimiter(
redis_client=get_redis_client(),
limit=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
window=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
)
if OAUTH_TOKEN_EXCHANGE_RATE_LIMIT is not None
else None
)
ADMIN_CONFIG_KEYS = {
'SHOW_ADMIN_DETAILS': 'auth.admin.show',
@@ -103,6 +120,7 @@ ADMIN_CONFIG_KEYS = {
'AUTOMATION_MIN_INTERVAL': 'automations.min_interval',
'ENABLE_AUTOMATIONS': 'automations.enable',
'ENABLE_CHANNELS': 'channels.enable',
'CHANNEL_MODEL_RESPONSE_MODE': 'channels.model_response_mode',
'ENABLE_CALENDAR': 'calendar.enable',
'ENABLE_MEMORIES': 'memories.enable',
'ENABLE_MEMORY_SYSTEM_CONTEXT': 'memories.system_context.enable',
@@ -128,6 +146,9 @@ LDAP_SERVER_CONFIG_KEYS = {
'certificate_path': 'ldap.server.ca_cert_file',
'validate_cert': 'ldap.server.validate_cert',
'ciphers': 'ldap.server.ciphers',
'enable_group_management': 'ldap.group.enable_management',
'enable_group_creation': 'ldap.group.enable_creation',
'attribute_for_groups': 'ldap.server.attribute_for_groups',
}
@@ -398,6 +419,52 @@ async def update_password(
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
def _unescape_ldap_dn_value(value: str) -> str:
"""Resolve RFC 4514 escapes in a DN value, e.g. ``CN=Sales\\, EMEA`` -> ``Sales, EMEA``.
Consecutive ``\\XX`` hex escapes encode UTF-8 bytes and are decoded together.
"""
hexdigits = '0123456789abcdefABCDEF'
result = []
pos = 0
length = len(value)
while pos < length:
char = value[pos]
if char == '\\' and pos + 1 < length:
if pos + 2 < length and value[pos + 1] in hexdigits and value[pos + 2] in hexdigits:
byte_values = bytearray()
while (
pos + 2 < length
and value[pos] == '\\'
and value[pos + 1] in hexdigits
and value[pos + 2] in hexdigits
):
byte_values.append(int(value[pos + 1 : pos + 3], 16))
pos += 3
result.append(byte_values.decode('utf-8', errors='replace'))
else:
# Backslash escaping a literal special char, e.g. "\," or "\+".
result.append(value[pos + 1])
pos += 2
else:
result.append(char)
pos += 1
return ''.join(result)
def extract_group_cn_from_dn(group_dn: str) -> str | None:
"""Return the first CN component of an LDAP group DN, or None.
Uses ``parse_dn`` so escaped separators inside a value (e.g. a group whose
name contains a comma) are handled correctly instead of naively splitting
on ``,``.
"""
for attr_type, attr_value, _ in parse_dn(group_dn):
if attr_type.upper() == 'CN':
return _unescape_ldap_dn_value(attr_value)
return None
############################
# LDAP Authentication
############################
@@ -545,17 +612,10 @@ async def ldap_auth(
log.info(f'Processing group DN #{group_idx + 1}: {group_dn}')
try:
group_cn = None
for item in group_dn.split(','):
item = item.strip()
if item.upper().startswith('CN='):
group_cn = item[3:]
break
group_cn = extract_group_cn_from_dn(group_dn)
if group_cn:
user_groups.append(group_cn)
else:
log.warning(f'Could not extract CN from group DN: {group_dn}')
except Exception as e:
@@ -627,9 +687,9 @@ async def ldap_auth(
if user:
if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups:
if ENABLE_LDAP_GROUP_CREATION:
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
try:
if ENABLE_LDAP_GROUP_CREATION:
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
await Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
log.info(f'Successfully synced groups for user {user.id}: {user_groups}')
except Exception as e:
@@ -681,14 +741,18 @@ async def signin(
pass
if not await Users.get_user_by_email(email.lower(), db=db):
await signup_handler(
request,
email,
str(uuid.uuid4()),
name,
db=db,
source='trusted_header',
)
try:
await signup_handler(
request,
email,
str(uuid.uuid4()),
name,
db=db,
source='trusted_header',
)
except IntegrityError:
if not await Users.get_user_by_email(email.lower(), db=db):
raise
user = await Auths.authenticate_user_by_email(email, db=db)
if user:
@@ -1141,6 +1205,7 @@ class AdminConfig(BaseModel):
AUTOMATION_MIN_INTERVAL: int | str | None = None
ENABLE_AUTOMATIONS: bool
ENABLE_CHANNELS: bool
CHANNEL_MODEL_RESPONSE_MODE: str = 'thread'
ENABLE_CALENDAR: bool
ENABLE_MEMORIES: bool
ENABLE_MEMORY_SYSTEM_CONTEXT: bool
@@ -1164,6 +1229,9 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep
if form_data.DEFAULT_USER_ROLE not in ['pending', 'user', 'admin']:
updates.pop('ui.default_user_role', None)
if form_data.CHANNEL_MODEL_RESPONSE_MODE not in ['thread', 'channel']:
updates.pop('channels.model_response_mode', None)
pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$'
# Check if the input string matches the pattern
@@ -1188,6 +1256,9 @@ class LdapServerConfig(BaseModel):
certificate_path: str | None = None
validate_cert: bool = True
ciphers: str | None = 'ALL'
enable_group_management: bool = False
enable_group_creation: bool = False
attribute_for_groups: str = 'memberOf'
@router.get('/admin/config/ldap/server', response_model=LdapServerConfig)
@@ -1209,6 +1280,11 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user
if not value:
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
# The group attribute is what group management reads from the directory
# entry; an empty value would make group sync silently do nothing.
if form_data.enable_group_management and not (form_data.attribute_for_groups or '').strip():
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY('attribute_for_groups'))
updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS)
updates['ldap.server.app_dn'] = form_data.app_dn or ''
updates['ldap.server.app_password'] = form_data.app_dn_password or ''
@@ -1240,6 +1316,7 @@ class OAuthConfigForm(BaseModel):
"""All OAuth/OIDC settings exposed to the admin panel."""
# General OAuth
ENABLE_OAUTH: bool | None = None
ENABLE_OAUTH_SIGNUP: bool | None = None
OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None
OAUTH_AUTO_REDIRECT: bool | None = None
@@ -1295,6 +1372,7 @@ OAUTH_COMMA_LIST_FIELDS = {
OAUTH_CONFIG_KEYS = {
'ENABLE_OAUTH': 'oauth.enable',
'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup',
'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email',
'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect',
@@ -1449,6 +1527,37 @@ class TokenExchangeForm(BaseModel):
token: str # OAuth access token from external provider
async def get_token_client_id(client, token: str) -> str | None:
"""Return the OAuth client_id a token was minted for, when the provider supports introspection."""
try:
metadata = await client.load_server_metadata()
introspection_endpoint = metadata.get('introspection_endpoint')
if not introspection_endpoint:
log.warning('Token exchange trusted-client check requires an introspection_endpoint')
return None
async with ClientSession(trust_env=True) as session:
async with session.post(
introspection_endpoint,
data={'token': token, 'token_type_hint': 'access_token'},
auth=BasicAuth(client.client_id, client.client_secret or ''),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
log.warning(f'Token introspection returned {r.status}')
return None
introspection = await r.json()
if not introspection.get('active'):
log.warning('Token introspection reports the token is inactive')
return None
return introspection.get('client_id')
except Exception as e:
log.warning(f'Token introspection failed: {e}')
return None
@router.post('/oauth/{provider}/token/exchange', response_model=SessionUserResponse)
async def token_exchange(
request: Request,
@@ -1467,6 +1576,14 @@ async def token_exchange(
detail='Token exchange is disabled',
)
if token_exchange_rate_limiter and token_exchange_rate_limiter.is_limited(
request.client.host if request.client else 'unknown'
):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
)
provider = provider.lower()
# Check if provider is configured
@@ -1484,6 +1601,20 @@ async def token_exchange(
detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
)
if OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS:
token_client_id = await get_token_client_id(client, form_data.token)
if not token_client_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unable to determine which client the token was issued to',
)
if token_client_id not in OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS:
log.warning('Token exchange denied: token was issued to an untrusted client for %s', provider)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Validate the token by calling the userinfo endpoint
try:
token_data = {'access_token': form_data.token, 'token_type': 'Bearer'}
+19 -8
View File
@@ -16,6 +16,7 @@ from open_webui.models.automations import (
Automations,
)
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.automations import (
@@ -56,16 +57,11 @@ async def check_automations_permission(request, user):
def check_automation_access(automation, user):
if not automation:
if not automation or user.id != automation.user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != 'admin' and user.id != automation.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
async def check_automation_limits(request, user, rrule_str: str, db, is_create: bool = False):
@@ -97,6 +93,17 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
)
async def check_automation_folder_access(folder_id: Optional[str], user, db: AsyncSession):
if folder_id is None:
return
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id, db=db)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse:
"""Full enrichment for single-item views (includes next_runs computation)."""
last_run = await AutomationRuns.get_latest(automation.id, db=db)
@@ -117,6 +124,7 @@ async def get_automation_items(
request: Request,
query: Optional[str] = None,
status: Optional[str] = None,
folder_id: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
@@ -130,6 +138,7 @@ async def get_automation_items(
user_id=user.id,
query=query,
status=status,
folder_id=folder_id,
skip=skip,
limit=limit,
db=db,
@@ -164,6 +173,7 @@ async def create_new_automation(
db: AsyncSession = Depends(get_async_session),
):
await check_automations_permission(request, user)
await check_automation_folder_access(form_data.folder_id, user, db)
try:
validate_rrule(form_data.data.rrule, tz=user.timezone)
except ValueError as e:
@@ -182,7 +192,7 @@ async def create_new_automation(
EVENTS.AUTOMATION_CREATED,
actor=user,
subject_id=automation.id,
data={'name': automation.name, 'is_active': automation.is_active},
data={'name': automation.name, 'is_active': automation.is_active, 'folder_id': automation.folder_id},
)
return response
@@ -221,6 +231,7 @@ async def update_automation_by_id(
await check_automations_permission(request, user)
automation = await Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
await check_automation_folder_access(form_data.folder_id, user, db)
try:
validate_rrule(form_data.data.rrule, tz=user.timezone)
@@ -240,7 +251,7 @@ async def update_automation_by_id(
EVENTS.AUTOMATION_UPDATED,
actor=user,
subject_id=updated.id,
data={'name': updated.name, 'is_active': updated.is_active},
data={'name': updated.name, 'is_active': updated.is_active, 'folder_id': updated.folder_id},
)
return response
+41 -30
View File
@@ -51,7 +51,6 @@ from open_webui.utils.models import (
get_all_models,
get_filtered_models,
)
from open_webui.utils.webhook import post_webhook
from pydantic import BaseModel, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
@@ -915,7 +914,6 @@ async def get_pinned_channel_messages(
async def send_notification(request, channel, message, active_user_ids, db=None):
name = request.app.state.WEBUI_NAME
webui_url = await Config.get('webui.url')
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
@@ -923,23 +921,29 @@ async def send_notification(request, channel, message, active_user_ids, db=None)
# Batch fetch channel members in 1 query (fixes N+1)
member_ids = {m.user_id for m in await Channels.get_members_by_channel_id(channel.id, db=db)}
url = f'{webui_url}/channels/{channel.id}'
for u in users:
if (u.id not in active_user_ids) and u.id in member_ids:
if enable_user_webhooks and u.settings:
webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None)
if webhook_url:
await post_webhook(
name,
webhook_url,
f'#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}',
{
'action': 'channel',
'message': message.content,
'title': channel.name,
'url': f'{webui_url}/channels/{channel.id}',
},
)
await publish_event(
request,
EVENTS.CHANNEL_MESSAGE,
subject_id=channel.id,
subject_type='channel',
data={
'user_id': u.id,
'channel_id': channel.id,
'message_id': message.id,
'sender_id': message.user_id,
'content': message.content,
'message': f'#{channel.name} - {url}\n\n{message.content}',
'content_preview': message.content[:300],
'title': channel.name,
'url': url,
},
message=channel.name,
)
return True
@@ -983,13 +987,20 @@ async def model_response_handler(request, channel, message, user, db=None):
db=db,
)
)[::-1]
response_parent_id = (
message.parent_id
if message.parent_id
else (
message.id if await Config.get('channels.model_response_mode', 'thread') == 'thread' else None
)
)
response_message, channel = await new_message_handler(
request,
channel.id,
MessageForm(
**{
'parent_id': (message.parent_id if message.parent_id else message.id),
'parent_id': response_parent_id,
'content': f'',
'data': {},
'meta': {
@@ -1496,12 +1507,13 @@ async def update_message_by_id(
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
else:
if (
user.role != 'admin'
and message.user_id != user.id
and not await channel_has_access(user.id, channel, permission='write', strict=False, db=db)
if user.role != 'admin' and not await channel_has_access(
user.id, channel, permission='write', strict=False, db=db
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
# Write access is not authorship — block cross-member edits.
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
await Messages.update_message_by_id(message_id, form_data, db=db)
@@ -1721,18 +1733,17 @@ async def delete_message_by_id(
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
else:
if (
user.role != 'admin'
and message.user_id != user.id
and not await channel_has_access(
user.id,
channel,
permission='write',
strict=False,
db=db,
)
if user.role != 'admin' and not await channel_has_access(
user.id,
channel,
permission='write',
strict=False,
db=db,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
# Write access is not authorship — block cross-member deletes.
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
await Messages.delete_message_by_id(message_id, db=db)
+390 -123
View File
@@ -6,14 +6,16 @@ import logging
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import (
AggregateChatStats,
ChatBody,
@@ -26,6 +28,7 @@ from open_webui.models.chats import (
ChatStatsExport,
ChatTitleIdResponse,
ChatUsageStatsListResponse,
is_internal_chat,
MessageStats,
)
from open_webui.models.folders import Folders
@@ -35,8 +38,9 @@ from open_webui.socket.main import get_event_emitter
from open_webui.tasks import has_active_tasks, stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.context_compaction import compact_chat_branch
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
from open_webui.utils.chat_fork import build_fork_history
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
from open_webui.utils.misc import get_message_list
from open_webui.utils.models import get_all_models
from pydantic import BaseModel
@@ -49,15 +53,93 @@ router = APIRouter()
SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
CHAT_CONFIG_KEYS = {
'CONTEXT_COMPACTION_MODEL': 'chat.context_compaction.model',
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage',
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
}
async def get_optional_verified_user(
request: Request,
response: Response,
background_tasks: BackgroundTasks,
auth_token: HTTPAuthorizationCredentials | None = Depends(bearer_security),
):
try:
user = await get_current_user(request, response, background_tasks, auth_token)
except HTTPException:
return None
if user.role not in {'user', 'admin'}:
return None
return user
async def is_open_shared_chat(shared, db: AsyncSession) -> bool:
return await AccessGrants.has_anyone_access(
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
async def can_read_shared_chat(user, shared, db: AsyncSession) -> bool:
if user.role == 'pending':
return False
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
return True
if shared.user_id == user.id:
return True
return await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
async def add_active_state_to_chat_list(
request: Request, chat_list: list[ChatTitleIdResponse]
) -> list[ChatTitleIdResponse]:
for chat in chat_list:
chat.active = False
if not await has_active_tasks(request.app.state.redis, chat.id):
continue
chat.active = await ChatMessages.has_unfinished_assistant_by_chat_id(chat.id)
return chat_list
async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
user_folders = await Folders.get_folders_by_user_id(user_id, db=db)
parent_by_id = {folder.id: folder.parent_id for folder in user_folders}
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
for unread_folder_id, unread_count in direct_unread_counts.items():
current_id = unread_folder_id
seen = set()
while current_id and current_id not in seen:
seen.add(current_id)
if current_id in unread_counts:
unread_counts[current_id] += unread_count
current_id = parent_by_id.get(current_id)
return unread_counts
class ChatConfigForm(BaseModel):
CONTEXT_COMPACTION_MODEL: str | None = ''
ENABLE_CONTEXT_COMPACTION: bool
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
@@ -104,7 +186,14 @@ def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) ->
async def get_chat_config_values() -> dict:
values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
return {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
if config.get('CONTEXT_COMPACTION_MODEL') is None:
config['CONTEXT_COMPACTION_MODEL'] = ''
if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None:
config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000)
if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None:
config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40
return config
def chat_config_updates(data: dict) -> dict:
@@ -131,10 +220,13 @@ async def require_chat_import_permission(request: Request, user, db: AsyncSessio
@router.get('/', response_model=list[ChatTitleIdResponse])
@router.get('/list', response_model=list[ChatTitleIdResponse])
async def get_session_user_chat_list(
request: Request,
user=Depends(get_verified_user),
page: int | None = None,
include_pinned: bool | None = False,
include_folders: bool | None = False,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
db: AsyncSession = Depends(get_async_session),
):
try:
@@ -142,26 +234,42 @@ async def get_session_user_chat_list(
limit = 60
skip = (page - 1) * limit
return await Chats.get_chat_title_id_list_by_user_id(
chats = await Chats.get_chat_title_id_list_by_user_id(
user.id,
include_folders=include_folders,
include_pinned=include_pinned,
sort_by=sort_by,
sort_dir=sort_dir,
skip=skip,
limit=limit,
db=db,
)
else:
return await Chats.get_chat_title_id_list_by_user_id(
chats = await Chats.get_chat_title_id_list_by_user_id(
user.id,
include_folders=include_folders,
include_pinned=include_pinned,
sort_by=sort_by,
sort_dir=sort_dir,
db=db,
)
return await add_active_state_to_chat_list(request, chats)
except Exception as e:
log.exception(e)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
@router.post('/read')
async def mark_chats_read_by_user_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
return {
'updated_count': await Chats.mark_chats_read_by_user_id(user.id, db=db),
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
}
############################
# GetChatUsageStats
# EXPERIMENTAL: may be removed in future releases
@@ -599,6 +707,7 @@ async def delete_all_user_chats(
@router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse])
async def get_user_chat_list_by_user_id(
request: Request,
user_id: str,
page: int | None = None,
query: str | None = None,
@@ -623,9 +732,10 @@ async def get_user_chat_list_by_user_id(
if direction:
filter['direction'] = direction
return await Chats.get_chat_list_by_user_id(
chats = await Chats.get_chat_list_by_user_id(
user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db
)
return await add_active_state_to_chat_list(request, chats)
############################
@@ -664,7 +774,7 @@ async def create_new_chat(
subject_id=chat.id,
data={'title': chat.title, 'folder_id': chat.folder_id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
except Exception as e:
log.exception(e)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
@@ -712,11 +822,16 @@ async def get_chat_config(user=Depends(get_admin_user)):
@router.post('/config', response_model=ChatConfigForm)
async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold))
retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE)))
await Config.upsert(
chat_config_updates(
{
**form_data.model_dump(),
'CONTEXT_COMPACTION_MODEL': form_data.CONTEXT_COMPACTION_MODEL or '',
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage,
}
)
)
@@ -730,6 +845,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user
@router.get('/search', response_model=list[ChatTitleIdResponse])
async def search_user_chats(
request: Request,
text: str,
page: int | None = None,
user=Depends(get_verified_user),
@@ -744,7 +860,17 @@ async def search_user_chats(
search_text = chat_search_content_text(text)
chat_list = []
for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db):
chat_list.append(ChatTitleIdResponse(**chat.model_dump(), snippet=chat_search_snippet(chat.chat, search_text)))
# Explicit fields: model_dump() would deep-copy the entire chat blob per row
chat_list.append(
ChatTitleIdResponse(
id=chat.id,
title=chat.title,
updated_at=chat.updated_at,
created_at=chat.created_at,
last_read_at=chat.last_read_at,
snippet=chat_search_snippet(chat.chat, search_text),
)
)
# Delete tag if no chat is found
words = text.strip().split(' ')
@@ -755,7 +881,7 @@ async def search_user_chats(
log.debug(f'deleting tag: {tag_id}')
await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
return chat_list
return await add_active_state_to_chat_list(request, chat_list)
############################
@@ -773,15 +899,18 @@ async def get_chats_by_folder_id(
folder_ids.extend([folder.id for folder in children_folders])
return [
ChatResponse(**chat.model_dump())
ChatResponse.model_validate(chat, from_attributes=True)
for chat in await Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id, db=db)
]
@router.get('/folder/{folder_id}/list')
@router.get('/folder/{folder_id}/list', response_model=list[ChatTitleIdResponse])
async def get_chat_list_by_folder_id(
request: Request,
folder_id: str,
page: int | None = 1,
sort_by: str = 'unread_updated_at',
sort_dir: str = 'desc',
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@@ -789,11 +918,16 @@ async def get_chat_list_by_folder_id(
limit = 10
skip = (page - 1) * limit
chats = await Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
return [
{'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at}
for chat in chats
]
chats = await Chats.get_chats_by_folder_id_and_user_id(
folder_id,
user.id,
skip=skip,
limit=limit,
sort_by=sort_by,
sort_dir=sort_dir,
db=db,
)
return await add_active_state_to_chat_list(request, chats)
except Exception as e:
log.exception(e)
@@ -806,8 +940,11 @@ async def get_chat_list_by_folder_id(
@router.get('/pinned', response_model=list[ChatTitleIdResponse])
async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
return await Chats.get_pinned_chats_by_user_id(user.id, db=db)
async def get_user_pinned_chats(
request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
chats = await Chats.get_pinned_chats_by_user_id(user.id, db=db)
return await add_active_state_to_chat_list(request, chats)
############################
@@ -838,7 +975,7 @@ async def generate_chat_export_ndjson(user_id: str):
for chat in result.items:
try:
yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n'
yield ChatResponse.model_validate(chat, from_attributes=True).model_dump_json() + '\n'
except Exception as e:
log.exception(f'Error serializing chat {chat.id}: {e}')
@@ -863,7 +1000,10 @@ async def get_user_chats(user=Depends(get_verified_user)):
@router.get('/all/archived', response_model=list[ChatResponse])
async def get_user_archived_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)]
return [
ChatResponse.model_validate(chat, from_attributes=True)
for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)
]
############################
@@ -890,7 +1030,7 @@ async def get_all_user_tags(user=Depends(get_verified_user), db: AsyncSession =
async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
if not ENABLE_ADMIN_EXPORT:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_chats(db=db)]
return [ChatResponse.model_validate(chat, from_attributes=True) for chat in await Chats.get_chats(db=db)]
############################
@@ -900,6 +1040,7 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSessio
@router.get('/archived', response_model=list[ChatTitleIdResponse])
async def get_archived_session_user_chat_list(
request: Request,
page: int | None = None,
query: str | None = None,
order_by: str | None = None,
@@ -921,13 +1062,14 @@ async def get_archived_session_user_chat_list(
if direction:
filter['direction'] = direction
return await Chats.get_archived_chat_list_by_user_id(
chats = await Chats.get_archived_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
return await add_active_state_to_chat_list(request, chats)
############################
@@ -1045,38 +1187,30 @@ async def get_shared_session_user_chat_list(
@router.get('/share/{share_id}', response_model=ChatResponse | None)
async def get_shared_chat_by_id(
share_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
share_id: str, user=Depends(get_optional_verified_user), db: AsyncSession = Depends(get_async_session)
):
if user.role == 'pending':
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
shared = await SharedChats.get_by_id(share_id, db=db)
if shared:
if await is_open_shared_chat(shared, db=db) or (
user is not None and await can_read_shared_chat(user, shared, db=db)
):
chat = await Chats.get_chat_by_share_id(share_id, db=db)
if chat:
return ChatResponse.model_validate(chat, from_attributes=True)
chat = await Chats.get_chat_by_share_id(share_id, db=db)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED if user else ERROR_MESSAGES.INVALID_TOKEN,
)
# Fallback: admins can also access any chat directly by chat ID
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = None
if user is not None and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(share_id, db=db)
if chat:
return ChatResponse.model_validate(chat, from_attributes=True)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
# Look up the original chat_id to check access grants (admins bypass)
if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS:
shared = await SharedChats.get_by_id(share_id, db=db)
if shared and shared.user_id != user.id:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
if not has_grant:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return ChatResponse(**chat.model_dump())
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
############################
@@ -1095,6 +1229,7 @@ class TagFilterForm(TagForm):
@router.post('/tags', response_model=list[ChatTitleIdResponse])
async def get_user_chat_list_by_tag_name(
request: Request,
form_data: TagFilterForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
@@ -1105,7 +1240,7 @@ async def get_user_chat_list_by_tag_name(
if len(chats) == 0:
await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db)
return chats
return await add_active_state_to_chat_list(request, chats)
############################
@@ -1136,7 +1271,8 @@ async def compact_chat_by_id(
history = (chat.chat or {}).get('history') or {}
messages_map = await Chats.get_messages_map_by_chat_id(id)
message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId'))
current_message_id = chat.current_message_id or history.get('currentId')
message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id)
model_id = (form_data.model if form_data else None) or next(
(message.get('model') for message in reversed(message_list) if message.get('model')),
None,
@@ -1149,6 +1285,7 @@ async def compact_chat_by_id(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.')
result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS)
result['context_usage'] = await get_chat_context_usage(chat, model_id)
if result.get('compacted'):
await publish_event(
request,
@@ -1169,31 +1306,36 @@ async def compact_chat_by_id(
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
# Check if user has access via access grants (shared_chat grants)
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(id, db=db)
else:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=id,
permission='read',
db=db,
)
if has_grant:
chat = await Chats.get_chat_by_id(id, db=db)
if not chat and user.role == 'admin':
candidate = await Chats.get_chat_by_id(id, db=db)
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
chat = candidate
# Check folder-based access (shared folders)
if not chat:
candidate = await Chats.get_chat_by_id(id, db=db)
if candidate and candidate.folder_id:
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
if folder and await has_folder_access(user.id, folder, 'read', db):
chat = candidate
# Access explicitly granted to this user applies to admins too, so an admin
# does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off.
if not chat:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=id,
permission='read',
db=db,
)
if has_grant:
chat = await Chats.get_chat_by_id(id, db=db)
# Check folder-based access (shared folders)
if not chat:
candidate = await Chats.get_chat_by_id(id, db=db)
if candidate and candidate.folder_id:
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
if folder and await has_folder_access(user.id, folder, 'read', db):
chat = candidate
if chat:
return ChatResponse(**chat.model_dump())
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
data['context_usage'] = await get_chat_context_usage(chat)
return data
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
@@ -1220,7 +1362,18 @@ async def update_chat_by_id(
form_data.chat.get('history'),
)
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
touch = 'history' in form_data.chat or 'messages' in form_data.chat
chat = await Chats.update_chat_by_id(id, updated_chat, db=db, touch=touch)
if form_data.variables is not None:
chat = (
await Chats.update_chat_variables_by_id(
id,
form_data.variables,
db=db,
touch=False,
)
or chat
)
# Reconcile chat_message rows without inferring deletes from missing IDs.
# Message deletion has its own endpoint below.
@@ -1235,7 +1388,7 @@ async def update_chat_by_id(
subject_id=id,
data={'title': chat.title},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1309,7 +1462,7 @@ async def update_chat_message_by_id(
subject_id=message_id,
data={'chat_id': id, 'content_preview': form_data.content[:300]},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
@router.delete('/{id}/messages/{message_id}', response_model=ChatResponse | None)
@@ -1348,7 +1501,7 @@ async def delete_chat_message_by_id(
subject_id=message_id,
data={'chat_id': id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
############################
@@ -1419,55 +1572,48 @@ async def delete_chat_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
# Cancel any in-flight LLM tasks (streaming, title/tags generation)
# before deleting the chat to prevent orphaned requests.
await stop_item_tasks(request.app.state.redis, id)
# Authorize before any side effect: cancelling a chat's in-flight tasks must
# not be reachable for a chat the caller may not delete.
if user.role == 'admin':
chat = await Chats.get_chat_by_id(id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
result = await Chats.delete_chat_by_id(id, db=db)
if result:
await publish_event(
request,
EVENTS.CHAT_DELETED,
actor=user,
subject_id=id,
data={'owner_id': chat.user_id},
)
return result
else:
if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Cancel any in-flight LLM tasks (streaming, title/tags generation) before
# deleting the chat to prevent orphaned requests.
await stop_item_tasks(request.app.state.redis, id)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
# Cascade to internal child chats spawned from this one.
for child_id in await Chats.get_internal_chat_ids_by_parent_id(id, chat.user_id):
await stop_item_tasks(request.app.state.redis, child_id)
await Chats.delete_chat_by_id_and_user_id(child_id, chat.user_id)
if user.role == 'admin':
result = await Chats.delete_chat_by_id(id, db=db)
else:
result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
if result:
await publish_event(
request,
EVENTS.CHAT_DELETED,
actor=user,
subject_id=id,
data={'owner_id': user.id},
)
return result
if result:
await publish_event(
request,
EVENTS.CHAT_DELETED,
actor=user,
subject_id=id,
data={'owner_id': chat.user_id},
)
return result
############################
@@ -1519,6 +1665,101 @@ class CloneForm(BaseModel):
title: str | None = None
class ForkForm(BaseModel):
message_id: str | None = None
@router.post('/{id}/fork', response_model=ChatResponse | None)
async def fork_chat_by_id(
request: Request,
id: str,
form_data: ForkForm | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await require_chat_import_permission(request, user, db)
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
if await has_active_tasks(request.app.state.redis, id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Wait for the current response to finish before forking.',
)
history = (chat.chat or {}).get('history') or {}
messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
if any(
message.get('role') == 'assistant' and message.get('done') is False
for message in messages_map.values()
if isinstance(message, dict)
):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Wait for the current response to finish before forking.',
)
source_message_id = (
(form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
)
if not source_message_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork')
try:
fork_history, fork_messages = build_fork_history(messages_map, source_message_id)
except ValueError as exc:
detail = str(exc)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST,
detail=detail,
) from exc
updated_chat = {**(chat.chat or {})}
updated_chat.pop('currentId', None)
updated_chat.update(
{
'originalChatId': chat.id,
'branchPointMessageId': source_message_id,
'title': f'{chat.title} (fork)',
'history': fork_history,
'messages': fork_messages,
}
)
meta = {
**(chat.meta or {}),
'forked_from': chat.id,
'forked_from_message_id': source_message_id,
}
fork = await Chats.insert_new_chat(
str(uuid4()),
user.id,
ChatForm(chat=updated_chat, folder_id=chat.folder_id),
db=db,
internal_meta=meta,
)
if fork and chat.variables:
fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork
if not fork:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
if chat.pinned:
fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
await publish_event(
request,
EVENTS.CHAT_CLONED,
actor=user,
subject_id=fork.id,
data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
)
return ChatResponse.model_validate(fork, from_attributes=True)
@router.post('/{id}/clone', response_model=ChatResponse | None)
async def clone_chat_by_id(
request: Request,
@@ -1545,6 +1786,7 @@ async def clone_chat_by_id(
**{
'chat': updated_chat,
'meta': chat.meta,
'variables': chat.variables or {},
'pinned': chat.pinned,
'folder_id': chat.folder_id,
}
@@ -1562,7 +1804,7 @@ async def clone_chat_by_id(
subject_id=chat.id,
data={'original_chat_id': id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -1601,7 +1843,7 @@ async def clone_shared_chat_by_id(
# Enforce access grants (owner and admins bypass)
shared = await SharedChats.get_by_id(id, db=db)
if shared and user.role != 'admin' and shared.user_id != user.id:
has_grant = await AccessGrants.has_access(
has_grant = await is_open_shared_chat(shared, db=db) or await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
@@ -1628,6 +1870,7 @@ async def clone_shared_chat_by_id(
**{
'chat': updated_chat,
'meta': chat.meta,
'variables': chat.variables or {},
'pinned': chat.pinned,
'folder_id': chat.folder_id,
}
@@ -1638,7 +1881,7 @@ async def clone_shared_chat_by_id(
if chats:
chat = chats[0]
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -1679,7 +1922,7 @@ async def archive_chat_by_id(
subject_id=id,
subject_type='chat',
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
@@ -1713,7 +1956,7 @@ async def share_chat_by_id(
subject_id=id,
data={'share_id': chat.share_id, 'updated': True},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
# Create a new share
shared = await SharedChats.create(id, user.id, db=db)
@@ -1731,7 +1974,7 @@ async def share_chat_by_id(
subject_id=id,
data={'share_id': shared.id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
# --- Delete Shared Chat ---
@@ -1795,11 +2038,13 @@ async def update_shared_chat_access_by_id(
user.role,
form_data.access_grants,
'sharing.public_chats',
'sharing.open_chats',
db=db,
)
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
############################
@@ -1844,6 +2089,28 @@ class ChatFolderIdForm(BaseModel):
folder_id: str | None = None
@router.post('/{id}/unread')
async def mark_chat_unread_by_id(
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.mark_chat_unread_by_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
folder_id = await Chats.get_chat_folder_id(id, user.id, db=db)
return {
'chat_id': id,
'last_read_at': chat.last_read_at,
'folder_id': folder_id,
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
}
@router.post('/{id}/folder', response_model=ChatResponse | None)
async def update_chat_folder_id_by_id(
request: Request,
@@ -1874,7 +2141,7 @@ async def update_chat_folder_id_by_id(
subject_id=id,
data={'folder_id': form_data.folder_id},
)
return ChatResponse(**chat.model_dump())
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
+66 -22
View File
@@ -67,6 +67,15 @@ MODELS_CONFIG_KEYS = {
'DEFAULT_MODEL_METADATA': 'models.default_metadata',
'DEFAULT_MODEL_PARAMS': 'models.default_params',
}
SUBAGENTS_CONFIG_KEYS = {
'ENABLE_SUBAGENTS': 'subagents.enable',
'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',
}
async def get_config_values(key_map: dict[str, str]) -> dict:
@@ -196,7 +205,7 @@ async def register_oauth_client(
log.debug(f'Failed to register OAuth client: {e}')
raise HTTPException(
status_code=400,
detail=f'Failed to register OAuth client',
detail=f'Failed to register OAuth client: {e}',
)
@@ -298,10 +307,8 @@ class TerminalServerConnection(BaseModel):
config: dict | None = None
# Orchestrator policy fields
server_type: str | None = None # "orchestrator", "terminal"
server_type: str | None = None
policy_id: str | None = None
policy: dict | None = None # cached policy data
model_config = ConfigDict(extra='allow')
@@ -321,7 +328,9 @@ async def set_terminal_servers_config(
form_data: TerminalServersConfigForm,
user=Depends(get_admin_user),
):
connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS]
connections = [
connection.model_dump(exclude={'policy', 'lifecycle'}) for connection in form_data.TERMINAL_SERVER_CONNECTIONS
]
await Config.upsert({'terminal_server.connections': connections})
await set_terminal_servers(request)
@@ -391,7 +400,7 @@ class TerminalServerPolicyForm(BaseModel):
key: str | None = ''
auth_type: str | None = 'bearer'
policy_id: str
policy_data: dict
policy_data: dict | None = None
class TerminalServerLifecycleForm(BaseModel):
@@ -399,7 +408,7 @@ class TerminalServerLifecycleForm(BaseModel):
key: str | None = ''
auth_type: str | None = 'bearer'
policy_id: str
lifecycle_data: dict
lifecycle_data: dict | None = None
class TerminalServerRefreshForm(BaseModel):
@@ -416,9 +425,7 @@ class TerminalServerRefreshForm(BaseModel):
async def put_terminal_server_policy(
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
):
"""
Proxy a policy PUT to an orchestrator terminal server.
"""
"""Proxy a policy read or update to an orchestrator terminal server."""
base_url = (form_data.url or '').rstrip('/')
if not base_url:
raise HTTPException(status_code=400, detail='Terminal server URL is required')
@@ -433,8 +440,12 @@ async def put_terminal_server_policy(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}'
async with session.put(
policy_url, headers=headers, json=form_data.policy_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
async with session.request(
'GET' if form_data.policy_data is None else 'PUT',
policy_url,
headers=headers,
json=form_data.policy_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
return await resp.json()
@@ -443,17 +454,15 @@ async def put_terminal_server_policy(
except HTTPException:
raise
except Exception as e:
log.debug(f'Failed to save policy to terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to save policy to terminal server')
log.debug(f'Failed to access policy on terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to access policy on terminal server')
@router.post('/terminal_servers/lifecycle')
async def put_terminal_server_lifecycle(
request: Request, form_data: TerminalServerLifecycleForm, user=Depends(get_admin_user)
):
"""
Proxy a policy lifecycle PUT to an orchestrator terminal server.
"""
"""Proxy a lifecycle read or update to an orchestrator terminal server."""
base_url = (form_data.url or '').rstrip('/')
if not base_url:
raise HTTPException(status_code=400, detail='Terminal server URL is required')
@@ -468,7 +477,8 @@ async def put_terminal_server_lifecycle(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
lifecycle_url = f'{base_url}/api/v1/policies/{form_data.policy_id}/lifecycle'
async with session.put(
async with session.request(
'GET' if form_data.lifecycle_data is None else 'PUT',
lifecycle_url,
headers=headers,
json=form_data.lifecycle_data,
@@ -481,8 +491,8 @@ async def put_terminal_server_lifecycle(
except HTTPException:
raise
except Exception as e:
log.debug(f'Failed to save lifecycle to terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to save lifecycle to terminal server')
log.debug(f'Failed to access lifecycle on terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to access lifecycle on terminal server')
@router.post('/terminal_servers/refresh')
@@ -604,7 +614,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
if form_data.headers and isinstance(form_data.headers, dict):
if headers is None:
headers = {}
custom_headers = get_custom_headers(form_data.headers, user)
custom_headers = await get_custom_headers(form_data.headers, user)
headers.update(custom_headers)
await client.connect(form_data.url, headers=headers)
@@ -649,7 +659,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
if form_data.headers and isinstance(form_data.headers, dict):
if headers is None:
headers = {}
custom_headers = get_custom_headers(form_data.headers, user)
custom_headers = await get_custom_headers(form_data.headers, user)
headers.update(custom_headers)
url = get_tool_server_url(form_data.url, form_data.path)
@@ -754,6 +764,40 @@ async def set_models_config(request: Request, form_data: ModelsConfigForm, user=
return values
class SubagentsConfigForm(BaseModel):
ENABLE_SUBAGENTS: bool
SUBAGENTS_BACKGROUND_ENABLED: bool
SUBAGENTS_MAX_CONCURRENT: int
SUBAGENTS_MAX_ASYNC: int
SUBAGENTS_MAX_ITERATIONS: int
SUBAGENTS_MAX_OUTPUT: int
SUBAGENTS_SYSTEM_PROMPT: str
@router.get('/subagents', response_model=SubagentsConfigForm)
async def get_subagents_config(user=Depends(get_admin_user)):
return await get_config_values(SUBAGENTS_CONFIG_KEYS)
@router.post('/subagents', response_model=SubagentsConfigForm)
async def set_subagents_config(
request: Request,
form_data: SubagentsConfigForm,
user=Depends(get_admin_user),
):
await Config.upsert(config_updates(form_data.model_dump(), SUBAGENTS_CONFIG_KEYS))
values = await get_config_values(SUBAGENTS_CONFIG_KEYS)
await publish_event(
request,
EVENTS.CONFIG_UPDATED,
actor=user,
subject_id='subagents',
subject_type='config',
data={'enabled': values.get('ENABLE_SUBAGENTS')},
)
return values
class PromptSuggestion(BaseModel):
title: list[str]
content: str
+2 -2
View File
@@ -424,7 +424,7 @@ async def create_feedback(
EVENTS.FEEDBACK_CREATED,
actor=user,
subject_id=feedback.id,
data={'rating': getattr(feedback, 'rating', None)},
data={'rating': (feedback.data or {}).get('rating')},
)
return feedback
@@ -463,7 +463,7 @@ async def update_feedback_by_id(
EVENTS.FEEDBACK_UPDATED,
actor=user,
subject_id=feedback.id,
data={'rating': getattr(feedback, 'rating', None)},
data={'rating': (feedback.data or {}).get('rating')},
)
return feedback
+39 -9
View File
@@ -108,6 +108,23 @@ def _cleanup_local_cache(file_path: str) -> None:
log.warning(f'Failed to clean up local cache for {file_path}: {e}')
def _matches_configured_mime_type(supported: list[str] | str, content_type: str) -> bool:
if isinstance(supported, str):
supported = supported.split(',')
supported = [item.strip() for item in (supported or []) if item.strip()]
if not supported:
return False
return bool(strict_match_mime_type(supported, content_type))
def _media_supported_for_extraction(
content_extraction_engine: str | None, supported: list[str] | str | None, content_type: str
) -> bool:
if supported is None:
return content_extraction_engine == 'external'
return bool(content_extraction_engine and _matches_configured_mime_type(supported, content_type))
async def process_uploaded_file(
request,
file,
@@ -127,6 +144,10 @@ async def process_uploaded_file(
content_type = 'text/plain'
stt_supported = await Config.get('audio.stt.supported_content_types', [])
content_extraction_engine = await Config.get('rag.content_extraction_engine')
content_extraction_supported_media_mime_types = await Config.get(
'rag.content_extraction.supported_media_mime_types'
)
if content_type and strict_match_mime_type(stt_supported, content_type):
# Audio / STT-supported files → transcribe then index
@@ -147,9 +168,10 @@ async def process_uploaded_file(
elif (
content_type
and content_type.startswith(('image/', 'video/'))
and await Config.get('rag.content_extraction_engine') != 'external'
and not _media_supported_for_extraction(
content_extraction_engine, content_extraction_supported_media_mime_types, content_type
)
):
# Media files without an external extraction engine
if content_type.startswith('video/'):
# Videos are stored as-is for downstream multimodal
# processing (Tools, vision models). Attempting text
@@ -165,7 +187,8 @@ async def process_uploaded_file(
raise Exception(f'File type {content_type} is not supported for processing')
else:
# Documents, or any file when an external engine is configured
# Documents, or media files explicitly enabled for the
# configured content extraction engine.
if not content_type:
log.info(f'File type {file.content_type} is not provided, but trying to process anyway')
await process_file(
@@ -201,21 +224,28 @@ async def process_uploaded_file(
f'{knowledge_id}: user {user.id} lacks write access'
)
else:
await Knowledges.add_file_to_knowledge_by_id(
knowledge_id=knowledge_id,
file_id=file_item.id,
user_id=user.id,
directory_id=file_metadata.get('directory_id'),
)
# Keep the generic file status stream open until the
# KB-specific vector write and durable link both finish.
await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session)
await process_file(
request,
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
user=user,
db=db_session,
)
knowledge_file = await Knowledges.add_file_to_knowledge_by_id(
knowledge_id=knowledge_id,
file_id=file_item.id,
user_id=user.id,
directory_id=file_metadata.get('directory_id'),
db=db_session,
)
if not knowledge_file:
raise Exception(f'Failed to link file {file_item.id} to knowledge {knowledge_id}')
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
except Exception as e:
log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}')
raise
except Exception as e:
log.error(f'Error processing file: {file_item.id}')
+138 -27
View File
@@ -6,12 +6,13 @@ import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.config import Config
from open_webui.models.chats import Chats
from open_webui.models.folders import (
@@ -22,14 +23,16 @@ from open_webui.models.folders import (
FolderUpdateForm,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.automations import Automations
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import (
filter_allowed_access_grants,
)
from open_webui.utils.access_control.files import get_accessible_folder_files
from open_webui.utils.access_control.files import can_read_all_folder_files, get_accessible_folder_files
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.tasks import has_active_tasks
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -42,6 +45,24 @@ router = APIRouter()
from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access
async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
folders = await Folders.get_folders_by_user_id(user_id, db=db)
parent_by_id = {folder.id: folder.parent_id for folder in folders}
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
for unread_folder_id, unread_count in direct_unread_counts.items():
current_id = unread_folder_id
seen = set()
while current_id and current_id not in seen:
seen.add(current_id)
if current_id in unread_counts:
unread_counts[current_id] += unread_count
current_id = parent_by_id.get(current_id)
return unread_counts
async def check_folders_permission(request: Request, user, db=None):
"""Verify the folders feature is enabled and the user has permission."""
config = await Config.get_many('folders.enable', 'user.permissions')
@@ -76,11 +97,12 @@ async def get_folders(
await check_folders_permission(request, user, db=db)
folders = await Folders.get_folders_by_user_id(user.id, db=db)
folder_ids = {folder.id for folder in folders}
# Verify folder data integrity
folder_list = []
for folder in folders:
if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db):
if folder.parent_id and folder.parent_id not in folder_ids:
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
if folder.data and 'files' in folder.data:
@@ -91,9 +113,14 @@ async def get_folders(
folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
)
folder_list.append(FolderNameIdResponse(**folder.model_dump()))
folder_list.append(folder)
return folder_list
unread_counts = await get_folder_unread_counts(user.id, db=db)
return [
FolderNameIdResponse(**folder.model_dump(), unread_count=unread_counts.get(folder.id, 0))
for folder in folder_list
]
############################
@@ -129,6 +156,18 @@ async def create_folder(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if form_data.data and 'files' in form_data.data:
owner = await Users.get_user_by_id(parent.user_id, db=db)
if not owner:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await can_read_all_folder_files(form_data.data['files'], owner, db=db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Create as the folder owner's subfolder (keep tree consistent)
try:
folder = await Folders.insert_new_folder(parent.user_id, form_data, form_data.parent_id, db=db)
@@ -147,6 +186,16 @@ async def create_folder(
detail=ERROR_MESSAGES.DEFAULT('Error creating folder'),
)
if (
form_data.data
and 'files' in form_data.data
and not await can_read_all_folder_files(form_data.data['files'], user, db=db)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
try:
folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db)
await publish_event(
@@ -288,11 +337,14 @@ async def update_folder_name_by_id(
detail=ERROR_MESSAGES.DEFAULT('Folder already exists'),
)
# Validate read access to every file/collection being attached.
# Folder files are consumed by chat middleware as RAG context.
if form_data.data and isinstance(form_data.data.get('files'), list):
accessible_files = await get_accessible_folder_files(form_data.data['files'], user, db=db)
if len(accessible_files) != len(form_data.data['files']):
if form_data.data and 'files' in form_data.data:
owner = user if folder.user_id == user.id else await Users.get_user_by_id(folder.user_id, db=db)
if not owner:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not await can_read_all_folder_files(form_data.data['files'], owner, db=db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -390,6 +442,11 @@ async def update_folder_is_expanded_by_id(
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if not folder:
folder = await Folders.get_folder_by_id(id, db=db)
if folder and (user.role == 'admin' or await _has_folder_access(user.id, folder, 'read', db)):
return folder
if folder:
try:
folder = await Folders.update_folder_is_expanded_by_id_and_user_id(
@@ -477,6 +534,9 @@ async def update_folder_access_by_id(
async def get_shared_folder_chats(
request: Request,
id: str,
page: int | None = Query(None, ge=1),
sort_by: str = Query('unread_updated_at'),
sort_dir: str = Query('desc'),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@@ -500,7 +560,18 @@ async def get_shared_folder_chats(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
limit = 10
skip = (page - 1) * limit if page is not None else 0
chats = await Chats.get_all_chats_by_folder_id(
id,
skip=skip,
limit=limit if page is not None else 60,
sort_by=sort_by,
sort_dir=sort_dir,
unread_for_user_id=user.id,
db=db,
)
total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats)
# Resolve owner names for display (avatar URLs are constructed client-side)
owner_cache: dict[str, str] = {}
@@ -510,11 +581,57 @@ async def get_shared_folder_chats(
u = await Users.get_user_by_id(uid, db=db)
owner_cache[uid] = u.name if u else 'Unknown'
chat['owner_name'] = owner_cache[uid]
chat['active'] = False
if chat['user_id'] != user.id:
chat['last_read_at'] = chat['updated_at']
if await has_active_tasks(request.app.state.redis, chat['id']):
chat['active'] = await ChatMessages.has_unfinished_assistant_by_chat_id(chat['id'], db=db)
return {
response = {
'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats],
'folder_permission': 'write' if has_write else 'read',
}
if page is not None:
response.update({'total': total, 'has_more': skip + limit < total})
return response
@router.post('/{id}/read')
async def mark_folder_chats_read_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_folders_permission(request, user, db=db)
folder = await Folders.get_folder_by_id(id, db=db)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
is_owner = user.id == folder.user_id
is_admin = user.role == 'admin'
if not (is_owner or is_admin or await _has_folder_access(user.id, folder, 'read', db)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder_ids = (
await Folders.get_folder_ids_by_id_and_user_id_in_subtree(id, folder.user_id, db=db)
if is_owner or is_admin
else [id]
)
updated_count = await Chats.mark_chats_read_by_folder_ids(user.id, folder_ids, db=db)
return {
'folder_id': id,
'folder_ids': folder_ids,
'updated_count': updated_count,
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
}
############################
@@ -534,26 +651,18 @@ async def delete_folder_by_id(
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if not folder:
# Check if it's a shared subfolder with write access
# Deletion cascades into the owner's data, so only the owner or an admin may delete
folder = await Folders.get_folder_by_id(id, db=db)
if folder and folder.parent_id:
if user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
elif folder and not folder.parent_id:
# Root shared folders can only be deleted by owner/admin
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder_owner_id = folder.user_id
@@ -585,6 +694,8 @@ async def delete_folder_by_id(
# Clean up access grants for this folder
await AccessGrants.revoke_all_access('folder', folder_id, db=db)
await Automations.clear_folder_ids(folder_owner_id, folder_ids, db=db)
await publish_event(
request,
EVENTS.FOLDER_DELETED,
+27 -2
View File
@@ -10,8 +10,8 @@ import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.events import EVENTS, publish_event
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
from open_webui.events import EVENTS, build_event, dispatch_event_functions, publish_event, schedule_webhook_dispatch
from open_webui.internal.db import get_async_session
from open_webui.models.functions import (
FunctionForm,
@@ -46,11 +46,17 @@ router = APIRouter()
@router.get('/', response_model=list[FunctionResponse])
async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
if not ENABLE_PLUGINS:
return []
return await Functions.get_functions(db=db)
@router.get('/list', response_model=list[FunctionUserResponse])
async def get_function_list(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
if not ENABLE_PLUGINS:
return []
return await Functions.get_function_list(db=db)
@@ -65,6 +71,9 @@ async def get_functions(
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
if not ENABLE_PLUGINS:
return []
return await Functions.get_functions(include_valves=include_valves, db=db)
@@ -284,6 +293,22 @@ async def toggle_function_by_id(
):
function = await Functions.get_function_by_id(id, db=db)
if function:
lifecycle_event = build_event(
request,
EVENTS.FUNCTION_DISABLE_STARTED if function.is_active else EVENTS.FUNCTION_ENABLE_STARTED,
actor=user,
subject_id=function.id,
subject_type='function',
data={'type': function.type, 'name': function.name},
)
await dispatch_event_functions(
request.app,
lifecycle_event,
request=request,
extra_function_ids=[function.id] if not function.is_active else None,
)
schedule_webhook_dispatch(request.app, lifecycle_event)
function = await Functions.update_function_by_id(id, {'is_active': not function.is_active}, db=db)
if function:
+10 -5
View File
@@ -13,6 +13,7 @@ from types import SimpleNamespace
from typing import Optional
from urllib.parse import quote, urlparse
import aiofiles
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
@@ -785,7 +786,11 @@ async def image_generations(
images.append({'url': url})
return images
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
if form_data.model:
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
# persists the global default and switches the shared backend. Only an
# admin may do that; a non-admin generates on the currently configured
# checkpoint. The model field is not a per-user selection on this backend.
if form_data.model and user.role == 'admin':
await set_image_model(request, form_data.model)
data = {
@@ -936,10 +941,10 @@ async def image_edits(
if isinstance(file_response, FileResponse):
file_path = file_response.path
with open(file_path, 'rb') as f:
file_bytes = f.read()
image_data = base64.b64encode(file_bytes).decode('utf-8')
mime_type, _ = mimetypes.guess_type(file_path)
async with aiofiles.open(file_path, 'rb') as f:
file_bytes = await f.read()
image_data = base64.b64encode(file_bytes).decode('utf-8')
mime_type, _ = mimetypes.guess_type(file_path)
return f'data:{mime_type};base64,{image_data}'
return data
+17 -2
View File
@@ -12,7 +12,7 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, RAG_EMBEDDING_CONTENT_PREFIX
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
@@ -21,6 +21,7 @@ from open_webui.models.config import Config
from open_webui.models.files import FileMetadataResponse, FileModel, FileModelResponse, Files
from open_webui.models.groups import Groups
from open_webui.models.knowledge import (
KNOWLEDGE_SORTABLE_FIELDS,
KnowledgeDirectoryForm,
KnowledgeDirectoryModel,
KnowledgeFileListResponse,
@@ -73,7 +74,7 @@ async def embed_knowledge_base_metadata(
"""Generate and store embedding for knowledge base."""
try:
content = f'{name}\n\n{description}' if description else name
embedding = await request.app.state.EMBEDDING_FUNCTION(content)
embedding = await request.app.state.EMBEDDING_FUNCTION(content, prefix=RAG_EMBEDDING_CONTENT_PREFIX)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=KNOWLEDGE_BASES_COLLECTION,
items=[
@@ -181,6 +182,8 @@ async def search_knowledge_bases(
view_option: str | None = None,
source: str | None = None,
page: int | None = 1,
order_by: str | None = None,
direction: str | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@@ -195,6 +198,10 @@ async def search_knowledge_bases(
filter['view_option'] = view_option
if source in {'local', 'external'}:
filter['source'] = source
if order_by in KNOWLEDGE_SORTABLE_FIELDS:
filter['order_by'] = order_by
if direction in {'asc', 'desc'}:
filter['direction'] = direction
groups = await Groups.get_groups_by_member_id(user.id, db=db)
user_group_ids = {group.id for group in groups}
@@ -1936,6 +1943,10 @@ async def sync_knowledge_cleanup(
if not file:
continue
# Only clean up files that belong to this knowledge base.
if not await Knowledges.has_file(id, file_id, db=db):
continue
await Knowledges.remove_file_from_knowledge_by_id(id, file_id, db=db)
try:
@@ -1960,6 +1971,10 @@ async def sync_knowledge_cleanup(
# ── Remove orphaned directories (children before parents) ──
for dir_id in reversed(form_data.dir_ids):
# Only delete directories that belong to this knowledge base.
directory = await Knowledges.get_directory_by_id(dir_id, db=db)
if not directory or directory.knowledge_id != id:
continue
await Knowledges.delete_directory(dir_id, move_files_to_parent=False, db=db)
return {'status': True}
+12 -5
View File
@@ -5,13 +5,13 @@ import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.config import RAG_EMBEDDING_CONTENT_PREFIX, RAG_EMBEDDING_QUERY_PREFIX
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.config import Config
from open_webui.models.memories import Memories, MemoryModel
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user
from open_webui.utils.memory import (
@@ -148,7 +148,9 @@ async def add_memory(
meta={'created_by': 'manual'},
)
vector = await request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
@@ -208,6 +210,7 @@ async def update_memories(
if result.get('status') in {'created', 'updated'}:
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user,
)
upsert_items.append(
@@ -284,7 +287,7 @@ async def query_memory(
if not memories:
raise HTTPException(status_code=404, detail='No memories found for user')
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, RAG_EMBEDDING_QUERY_PREFIX, user=user)
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user)
results = await ASYNC_VECTOR_DB_CLIENT.search(
collection_name=f'user-memory-{user.id}',
@@ -407,7 +410,9 @@ async def reset_memory_from_vector_db(
# Generate vectors in parallel
vectors = await asyncio.gather(
*[
request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
for memory in memories
]
)
@@ -503,7 +508,9 @@ async def update_memory_by_id(
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND)
if form_data.content is not None or form_data.path is not None:
vector = await request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
+32 -12
View File
@@ -40,6 +40,7 @@ from open_webui.models.models import (
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.chat_variables import get_chat_variables_schema
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,6 +49,14 @@ log = logging.getLogger(__name__)
router = APIRouter()
def add_chat_variables_schema(model_dict: dict) -> dict:
system = (model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None
schema = get_chat_variables_schema(system)
if schema:
model_dict.setdefault('meta', {})['chat_variables_schema'] = schema
return model_dict
def _safe_static_redirect_path(url: str) -> str | None:
"""
If url is a same-origin static asset path, return a normalized path safe for
@@ -119,7 +128,11 @@ async def _verify_knowledge_file_access(
PAGE_ITEM_COUNT = 30
@router.get('/list', response_model=ModelAccessListResponse) # do NOT use "/" as path, conflicts with main.py
@router.get(
'/list',
response_model=ModelAccessListResponse,
response_model_exclude={'items': {'__all__': {'meta': {'profile_image_url'}}}},
) # do NOT use "/" as path, conflicts with main.py
async def get_models(
query: str | None = None,
view_option: str | None = None,
@@ -173,19 +186,19 @@ async def get_models(
# Strip profile_image_url from meta — images are served via /model/profile/image.
items = []
for model in result.items:
data = model.model_dump()
data = add_chat_variables_schema(model.model_dump())
if data.get('meta'):
data['meta'].pop('profile_image_url', None)
items.append(
ModelAccessResponse(
**data,
write_access=(
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or model.id in writable_model_ids
),
)
write_access = (
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or model.id in writable_model_ids
)
# Strip params (system prompt and other curated config) for read-only
# callers, mirroring the per-id endpoint.
if not write_access:
data['params'] = {}
items.append(ModelAccessResponse(**data, write_access=write_access))
return ModelAccessListResponse(
items=items,
@@ -415,7 +428,10 @@ async def import_models(
continue
# Update existing model
model_data['meta'] = model_data.get('meta', {})
model_data['meta'] = {
**existing_model.meta.model_dump(),
**(model_data.get('meta') or {}),
}
model_data['params'] = model_data.get('params', {})
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
@@ -520,6 +536,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
db=db,
):
model_dict = model.model_dump()
model_dict = add_chat_variables_schema(model_dict)
# Strip params (system prompt and other admin-curated config)
# for read-only callers — matches the params strip already
# enforced on /api/models in utils/models.py. Owners, admins
@@ -721,6 +738,9 @@ async def update_model_by_id(
if 'base_model_id' not in form_data.model_fields_set:
form_data.base_model_id = model.base_model_id
if 'profile_image_url' not in form_data.meta.model_fields_set:
form_data.meta.profile_image_url = model.meta.profile_image_url
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
+233 -2
View File
@@ -1,6 +1,7 @@
import json
import logging
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from open_webui.config import (
@@ -12,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.chats import ChatForm, ChatResponse, Chats
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.notes import (
@@ -303,6 +305,229 @@ async def get_note_by_id(
)
@router.get('/{id}/chat', response_model=ChatResponse)
async def get_note_chat_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
log.info('[note-chat] get-or-create requested note_id=%s user_id=%s', id, user.id)
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = await Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and (
user.id != note.user_id
and not await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
db=db,
)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
chat = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db)
if chat:
log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
payload = {**(chat.chat or {})}
params = {**(payload.get('params') or {})}
changed = False
if params.pop('note_id', None) is not None:
changed = True
system = (
f'CONTEXT:\nCurrent note id: {note.id}\n'
'This chat is attached to the current note.\n'
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
'Do not say an edit is done unless replace_note_content succeeds.'
)
if params.get('system') != system:
params['system'] = system
changed = True
if payload.pop('system', None) is not None:
changed = True
payload['params'] = params
if changed:
updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False)
if updated_chat:
return updated_chat
return chat
chat_id = str(uuid4())
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': 'Chat',
'models': [''],
'params': {
'system': (
f'CONTEXT:\nCurrent note id: {note.id}\n'
'This chat is attached to the current note.\n'
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
'Do not say an edit is done unless replace_note_content succeeds.'
)
},
'history': {'messages': {}, 'currentId': None},
'messages': [],
'tags': [],
}
),
db=db,
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
)
if not chat:
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
return chat
@router.get('/{id}/chats', response_model=list[ChatResponse])
async def get_note_chats_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = await Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and (
user.id != note.user_id
and not await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
db=db,
)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db)
normalized_chats = []
for chat in chats:
payload = {**(chat.chat or {})}
params = {**(payload.get('params') or {})}
changed = False
if params.pop('note_id', None) is not None:
changed = True
system = (
f'CONTEXT:\nCurrent note id: {note.id}\n'
'This chat is attached to the current note.\n'
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
'Do not say an edit is done unless replace_note_content succeeds.'
)
if params.get('system') != system:
params['system'] = system
changed = True
if payload.pop('system', None) is not None:
changed = True
payload['params'] = params
if changed:
chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) or chat
normalized_chats.append(chat)
return normalized_chats
@router.post('/{id}/chat', response_model=ChatResponse)
async def create_note_chat_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = await Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and (
user.id != note.user_id
and not await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
db=db,
)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
chat_id = str(uuid4())
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': 'Chat',
'models': [''],
'params': {
'system': (
f'CONTEXT:\nCurrent note id: {note.id}\n'
'This chat is attached to the current note.\n'
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
'Do not say an edit is done unless replace_note_content succeeds.'
)
},
'history': {'messages': {}, 'currentId': None},
'messages': [],
'tags': [],
}
),
db=db,
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
)
if not chat:
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
return chat
############################
# UpdateNoteById
############################
@@ -354,9 +579,15 @@ async def update_note_by_id(
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
note.is_pinned = note.id in pinned_note_ids
event_data = note.model_dump()
if form_data.data is not None:
event_data['data'] = {
key: note.data.get(key) for key in form_data.data.keys() if note.data is not None and key in note.data
}
await sio.emit(
'note-events',
note.model_dump(),
'events:note',
event_data,
to=f'note:{note.id}',
)
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
from open_webui.models.config import Config
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user
from open_webui.utils.notifications import (
create_target,
delete_target,
get_notification_event_catalog,
list_targets,
set_default_target,
test_target,
update_target,
)
router = APIRouter()
class NotificationTargetForm(BaseModel):
id: str | None = None
type: str | None = None
enabled: bool | None = None
events: list[str] | None = None
delivery: str | None = None
config: dict[str, Any] | None = None
async def _check_notifications_access(user) -> None:
if not await Config.get('ui.enable_user_webhooks'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and not await has_permission(
user.id, 'features.webhooks', await Config.get('user.permissions')
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
@router.get('/events')
async def get_notification_events(user=Depends(get_verified_user)):
await _check_notifications_access(user)
return {'events': get_notification_event_catalog()}
@router.get('/targets')
async def get_notification_targets(user=Depends(get_verified_user)):
await _check_notifications_access(user)
return await list_targets(user.id)
@router.post('/targets')
async def create_notification_target(form_data: NotificationTargetForm, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
return await create_target(user.id, form_data.model_dump(exclude_unset=True))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.put('/targets/{target_id}')
async def update_notification_target(
target_id: str, form_data: NotificationTargetForm, user=Depends(get_verified_user)
):
await _check_notifications_access(user)
try:
return await update_target(user.id, target_id, form_data.model_dump(exclude_unset=True))
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete('/targets/{target_id}')
async def delete_notification_target(target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
if not await delete_target(user.id, target_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
return {'ok': True}
@router.put('/targets/{target_id}/default')
async def set_default_notification_target(target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
return await set_default_target(user.id, target_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post('/targets/{target_id}/test')
async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)):
await _check_notifications_access(user)
try:
app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI')
return await test_target(user.id, target_id, app_name)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+191 -117
View File
@@ -11,6 +11,7 @@ from datetime import datetime
from typing import Optional, Union
from urllib.parse import urlparse
import aiofiles
import aiohttp
from aiocache import cached
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
@@ -23,8 +24,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
@@ -39,13 +40,15 @@ from open_webui.models.users import UserModel
from open_webui.utils.access_control import check_model_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import calculate_sha256
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
apply_model_params_to_body_openai,
apply_system_prompt_to_body,
)
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper
log = logging.getLogger(__name__)
@@ -54,6 +57,7 @@ log = logging.getLogger(__name__)
# clients to attempt decompression of an already-decoded payload, resulting
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
def _clean_proxy_headers(raw_headers) -> dict:
@@ -81,9 +85,9 @@ async def send_get_request(
url,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
timeout=_MODEL_LIST_TIMEOUT,
) as r:
return await r.json()
return await r.json(loads=JSONCodec.loads)
except Exception as exc:
log.error(f'Connection error: {exc}')
return None
@@ -97,6 +101,8 @@ async def send_request(
key: str | None = None,
user: UserModel = None,
stream: bool = False,
# passthrough must stay False for /api/chat: middleware parses it per line
passthrough: bool = False,
content_type: str | None = None,
metadata: dict | None = None,
api_config: dict | None = None,
@@ -119,7 +125,7 @@ async def send_request(
# Custom per-connection headers last so admin-set headers take precedence.
if api_config and api_config.get('headers'):
headers.update(get_custom_headers(api_config['headers'], user, metadata, request=request))
headers.update(await get_custom_headers(api_config['headers'], user, metadata, request=request))
r = await session.request(
method,
@@ -127,12 +133,12 @@ async def send_request(
data=payload,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(stream=stream),
)
if not r.ok:
try:
res = await r.json()
res = await r.json(loads=JSONCodec.loads)
await publish_model_provider_request_failed(
request,
actor=user,
@@ -168,13 +174,13 @@ async def send_request(
streaming = True
return StreamingResponse(
stream_wrapper(r),
stream_wrapper(r, passthrough=passthrough),
status_code=r.status,
headers=response_headers,
)
else:
try:
return await r.json()
return await r.json(loads=JSONCodec.loads)
except Exception:
return None
@@ -261,16 +267,16 @@ async def verify_connection(
f'{form_data.url}/api/version',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
timeout=_MODEL_LIST_TIMEOUT,
) as r:
if r.status != 200:
detail = f'HTTP Error: {r.status}'
res = await r.json()
res = await r.json(loads=JSONCodec.loads)
if 'error' in res:
detail = f'External Error: {res["error"]}'
raise Exception(detail)
return await r.json()
return await r.json(loads=JSONCodec.loads)
except aiohttp.ClientError as exc:
log.exception(f'Client error: {exc}')
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
@@ -313,6 +319,16 @@ async def update_config(
'ollama.api_configs': api_configs,
}
)
await get_all_models.cache.clear()
request.app.state.BASE_MODELS = []
request.app.state.OLLAMA_MODELS = {}
models = getattr(request.app.state, 'MODELS', None)
if hasattr(models, 'clear'):
models.clear()
else:
request.app.state.MODELS = {}
await publish_event(
request,
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
@@ -355,6 +371,12 @@ def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict:
return api_configs.get(str(idx), api_configs.get(url, {}))
async def get_ollama_connection_config() -> tuple[list, dict]:
"""Base URLs and per-connection API configs in one batched SELECT."""
config = await Config.get_many('ollama.base_urls', 'ollama.api_configs')
return config.get('ollama.base_urls', []), config.get('ollama.api_configs', {})
@cached(
ttl=MODELS_CACHE_TTL,
# key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a
@@ -517,6 +539,7 @@ async def get_ollama_loaded_models(
@router.get('/api/version/{url_idx}')
async def get_ollama_versions(
request: Request,
user=Depends(get_verified_user),
url_idx: int | None = None,
):
"""Return the lowest Ollama version across all configured backends."""
@@ -638,6 +661,7 @@ async def pull_model(
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
passthrough=True,
)
@@ -677,6 +701,7 @@ async def push_model(
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
passthrough=True,
)
@@ -709,6 +734,7 @@ async def create_model(
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
stream=True,
passthrough=True,
)
@@ -871,16 +897,13 @@ async def embed(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
base_urls, api_configs = await get_ollama_connection_config()
url = base_urls[url_idx]
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
key = get_api_key(url_idx, url, api_configs)
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
return await send_request(
f'{url}/api/embed',
@@ -925,16 +948,13 @@ async def embeddings(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
base_urls, api_configs = await get_ollama_connection_config()
url = base_urls[url_idx]
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
key = get_api_key(url_idx, url, api_configs)
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
return await send_request(
f'{url}/api/embeddings',
@@ -984,22 +1004,20 @@ async def generate_completion(
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
url_idx = random.choice(models[model]['urls'])
url = (await Config.get('ollama.base_urls', []))[url_idx]
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}),
)
base_urls, api_configs = await get_ollama_connection_config()
url = base_urls[url_idx]
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
prefix_id = api_config.get('prefix_id')
if prefix_id:
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
return await send_request(
f'{url}/api/generate',
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=True,
passthrough=True,
)
@@ -1051,6 +1069,10 @@ async def get_ollama_url(request: Request, model: str, url_idx: int | None = Non
await validate_ollama_backend_idx(request, model, url_idx, user)
if url_idx is None:
models = request.app.state.OLLAMA_MODELS
if not models or model not in models:
await get_all_models.cache.clear()
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model not in models:
raise HTTPException(
status_code=400,
@@ -1095,7 +1117,7 @@ async def generate_chat_completion(
raise HTTPException(status_code=400, detail=str(exc))
if isinstance(form_data, BaseModel):
payload = {**form_data.model_dump(exclude_none=True)}
payload = form_data.model_dump(exclude_none=True)
payload.pop('metadata', None)
@@ -1119,16 +1141,16 @@ async def generate_chat_completion(
await check_model_access(user, None, bypass_filter)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_configs = await Config.get('ollama.api_configs', {})
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/api/chat',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=form_data.stream,
content_type='application/x-ndjson',
@@ -1170,6 +1192,14 @@ class OpenAICompletionForm(BaseModel):
model_config = ConfigDict(extra='allow')
class OpenAIEmbeddingsForm(BaseModel):
"""Payload for the OpenAI-compatible /v1/embeddings proxy."""
model: str
input: object
model_config = ConfigDict(extra='allow')
@router.post('/v1/completions')
@router.post('/v1/completions/{url_idx}')
async def generate_openai_completion(
@@ -1191,8 +1221,7 @@ async def generate_openai_completion(
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])}
payload.pop('metadata', None)
payload = form_data.model_dump(exclude_none=True, exclude=['metadata'])
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
@@ -1207,18 +1236,68 @@ async def generate_openai_completion(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_configs = await Config.get('ollama.api_configs', {})
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/completions',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
passthrough=True,
metadata=metadata,
api_config=api_config,
request=request,
)
@router.post('/v1/embeddings')
@router.post('/v1/embeddings/{url_idx}')
async def generate_openai_embeddings(
request: Request,
form_data: dict,
url_idx: int | None = None,
user=Depends(get_verified_user), # noqa: B008
):
"""Forward an embeddings request via the OpenAI-compatible proxy."""
if not await Config.get('ollama.enable'):
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
metadata = form_data.pop('metadata', None)
try:
form_data = OpenAIEmbeddingsForm(**form_data)
except Exception as exc:
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = form_data.model_dump(exclude_none=True)
payload.pop('metadata', None)
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
if model_info is not None:
if model_info.base_model_id:
payload['model'] = model_info.base_model_id
await check_model_access(user, model_info)
else:
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
prefix_id = api_config.get('prefix_id')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/embeddings',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
user=user,
metadata=metadata,
api_config=api_config,
request=request,
@@ -1246,8 +1325,7 @@ async def generate_openai_chat_completion(
log.exception(exc)
raise HTTPException(status_code=400, detail=str(exc))
payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])}
payload.pop('metadata', None)
payload = form_data.model_dump(exclude_none=True, exclude=['metadata'])
model_id = form_data.model
model_info = await Models.get_model_by_id(model_id)
@@ -1266,18 +1344,19 @@ async def generate_openai_chat_completion(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
api_configs = await Config.get('ollama.api_configs', {})
api_config = resolve_api_config(api_configs, url_idx, url)
prefix_id = api_config.get('prefix_id')
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/chat/completions',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
passthrough=True,
metadata=metadata,
api_config=api_config,
request=request,
@@ -1317,21 +1396,19 @@ async def generate_anthropic_messages(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
)
api_configs = await Config.get('ollama.api_configs', {})
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/messages',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
passthrough=True,
content_type='text/event-stream' if payload.get('stream', False) else None,
api_config=api_config,
request=request,
@@ -1377,21 +1454,19 @@ async def generate_responses(
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
api_config = (await Config.get('ollama.api_configs', {})).get(
str(url_idx),
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
)
api_configs = await Config.get('ollama.api_configs', {})
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
return await send_request(
f'{url}/v1/responses',
payload=json.dumps(payload),
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
key=get_api_key(url_idx, url, api_configs),
user=user,
stream=payload.get('stream', False),
passthrough=True,
content_type='text/event-stream' if payload.get('stream', False) else None,
api_config=api_config,
request=request,
@@ -1462,7 +1537,7 @@ async def download_file_stream(
file_url: str,
file_path: str,
file_name: str,
chunk_size: int = 1024 * 1024,
chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE,
):
"""Stream a model file download from *file_url*, then push the blob to Ollama."""
current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
@@ -1477,37 +1552,38 @@ async def download_file_stream(
) as response:
total_size = int(response.headers.get('content-length', 0)) + current_size
with open(file_path, 'ab+') as f:
async with aiofiles.open(file_path, 'ab') as f:
async for data in response.content.iter_chunked(chunk_size):
current_size += len(data)
f.write(data)
await f.write(data)
done = current_size == total_size
progress = round((current_size / total_size) * 100, 2)
progress_total = total_size or current_size
progress = round((current_size / progress_total) * 100, 2)
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
if done:
f.close()
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
done = True
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
def _read_blob():
with open(file_path, 'rb') as blob_f:
return blob_f.read()
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
blob_size = await asyncio.to_thread(os.path.getsize, file_path)
blob_data = await asyncio.to_thread(_read_blob)
async def blob_chunks():
async with aiofiles.open(file_path, 'rb') as blob_file:
while chunk := await blob_file.read(chunk_size):
yield chunk
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
async with session.post(
blob_url,
data=blob_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=30),
) as blob_resp:
if blob_resp.ok:
os.remove(file_path)
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
else:
raise RuntimeError('Ollama: Could not create blob, Please try again.')
async with session.post(
blob_url,
data=blob_chunks(),
headers={'Content-Length': str(blob_size)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=30),
) as blob_resp:
if blob_resp.ok:
await asyncio.to_thread(os.remove, file_path)
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
else:
raise RuntimeError('Ollama: Could not create blob, Please try again.')
@router.post('/models/download')
@@ -1554,17 +1630,11 @@ async def upload_model(
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Stage 1: persist the uploaded file to disk
chunk_size = 1024 * 1024 * 2 # 2 MiB
chunk_size = AIOHTTP_FILE_STREAM_CHUNK_SIZE
def _persist_upload():
with open(file_path, 'wb') as out_f:
while True:
chunk = file.file.read(chunk_size)
if not chunk:
break
out_f.write(chunk)
await asyncio.to_thread(_persist_upload)
async with aiofiles.open(file_path, 'wb') as out_f:
while chunk := await file.read(chunk_size):
await out_f.write(chunk)
async def file_process_stream():
nonlocal ollama_url
@@ -1576,33 +1646,34 @@ async def upload_model(
log.info(f'Model Hash: {file_hash}')
try:
with open(file_path, 'rb') as f:
bytes_read = 0
while chunk := f.read(chunk_size):
bytes_read = 0
async with aiofiles.open(file_path, 'rb') as f:
while chunk := await f.read(chunk_size):
bytes_read += len(chunk)
progress = round(bytes_read / total_size * 100, 2)
yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n'
# Stage 3: push blob to Ollama
def _read_blob():
with open(file_path, 'rb') as f:
return f.read()
blob_data = await asyncio.to_thread(_read_blob)
event = json.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read})
yield f'data: {event}\n\n'
session = await get_session()
blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}'
async def blob_chunks():
async with aiofiles.open(file_path, 'rb') as blob_file:
while chunk := await blob_file.read(chunk_size):
yield chunk
async with session.post(
blob_url,
data=blob_data,
data=blob_chunks(),
headers={'Content-Length': str(total_size)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(),
) as resp:
if not resp.ok:
raise Exception('Ollama: Could not create blob, Please try again.')
log.info('Uploaded to /api/blobs')
os.remove(file_path)
await asyncio.to_thread(os.remove, file_path)
# Stage 4: create the model
model, _ext = os.path.splitext(filename)
@@ -1619,11 +1690,14 @@ async def upload_model(
headers={'Content-Type': 'application/json'},
data=json.dumps(create_payload),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(),
) as create_resp:
if create_resp.ok:
log.info('API SUCCESS!')
yield f'data: {json.dumps({"done": True, "blob": f"sha256:{file_hash}", "name": filename, "model_created": model})}\n\n'
event = json.dumps(
{'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model}
)
yield f'data: {event}\n\n'
else:
resp_text = await create_resp.text()
raise Exception(f'Failed to create model in Ollama. {resp_text}')
+142 -57
View File
@@ -8,6 +8,7 @@ import re
from typing import Optional
from urllib.parse import quote, urlparse
import aiofiles
import aiohttp
from aiocache import cached
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
@@ -25,7 +26,6 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_FORWARD_USER_INFO_HEADERS,
@@ -43,6 +43,8 @@ from open_webui.utils.access_control import check_model_access, has_connection_a
from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
stream_chunks_handler,
@@ -53,6 +55,7 @@ from open_webui.utils.payload import (
)
from open_webui.utils.session_pool import (
cleanup_response,
get_client_timeout,
get_session,
stream_wrapper,
)
@@ -75,6 +78,8 @@ log = logging.getLogger(__name__)
# clients to attempt decompression of an already-decoded payload, resulting
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
_UNSUPPORTED_OPENAI_MODEL_KEYWORDS = ('babbage', 'dall-e', 'davinci', 'embedding', 'tts', 'whisper')
def _clean_proxy_headers(raw_headers) -> dict:
@@ -89,9 +94,8 @@ async def send_get_request(
user: UserModel = None,
config=None,
):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with aiohttp.ClientSession(timeout=_MODEL_LIST_TIMEOUT, trust_env=True) as session:
if request and config:
headers, cookies = await get_headers_and_cookies(request, url, key, config, user=user)
else:
@@ -109,7 +113,7 @@ async def send_get_request(
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await response.json()
return await response.json(loads=JSONCodec.loads)
except Exception as e:
# Handle connection error here
log.error(f'Connection error: {e}')
@@ -209,7 +213,7 @@ async def get_headers_and_cookies(
headers['Authorization'] = f'Bearer {token}'
if config.get('headers') and isinstance(config.get('headers'), dict):
custom_headers = get_custom_headers(config.get('headers'), user, metadata, request=request)
custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request)
headers.update(custom_headers)
return headers, cookies
@@ -301,6 +305,90 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
return url, key, api_config
async def get_anthropic_token_count_target(request: Request, form_data: dict, user: UserModel):
"""Resolve the upstream LiteLLM connection for an Anthropic token-count request."""
requested_model = form_data.get('model')
if not requested_model:
raise HTTPException(status_code=400, detail='model is required')
payload = {**form_data}
model_id = requested_model
model_info = await Models.get_model_by_id(model_id)
await check_model_access(user, model_info, BYPASS_MODEL_ACCESS_CONTROL)
if model_info and model_info.base_model_id:
model_id = model_info.base_model_id
payload['model'] = model_id
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
model = models.get(model_id)
if not model or 'urlIdx' not in model:
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.MODEL_NOT_FOUND())
url, key, api_config = await get_openai_connection(model['urlIdx'])
prefix_id = api_config.get('prefix_id')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
return requested_model, payload, url, key, headers, cookies
async def count_anthropic_tokens(request: Request, form_data: dict, user: UserModel) -> int:
"""Forward an Anthropic token-count request through an OpenAI-compatible connection."""
requested_model, payload, url, key, headers, cookies = await get_anthropic_token_count_target(
request, form_data, user
)
request_url = f'{url.rstrip("/")}/messages/count_tokens'
response = None
try:
session = await get_session()
response = await session.request(
method='POST',
url=request_url,
data=json.dumps(payload),
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=get_client_timeout(),
)
try:
response_data = await response.json(loads=JSONCodec.loads)
except Exception:
response_data = await response.text()
if response.status >= 400:
await publish_model_provider_request_failed(
request,
actor=user,
provider='openai-compatible',
base_url=url,
api_key=key,
status=response.status,
requested_model=requested_model,
upstream_error=response_data,
)
raise HTTPException(status_code=response.status, detail=response_data)
input_tokens = response_data.get('input_tokens') if isinstance(response_data, dict) else None
if isinstance(input_tokens, bool) or not isinstance(input_tokens, int) or input_tokens < 0:
raise HTTPException(status_code=502, detail='Invalid token-count response from upstream provider')
return input_tokens
except HTTPException:
raise
except Exception:
log.exception('Failed to count Anthropic tokens for model %s', requested_model)
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
finally:
await cleanup_response(response)
@router.get('/config')
async def get_config(request: Request, user=Depends(get_admin_user)):
return await get_openai_config()
@@ -333,6 +421,16 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe
'openai.api_configs': api_configs,
}
)
await get_all_models.cache.clear()
request.app.state.BASE_MODELS = []
request.app.state.OPENAI_MODELS = {}
models = getattr(request.app.state, 'MODELS', None)
if hasattr(models, 'clear'):
models.clear()
else:
request.app.state.MODELS = {}
await publish_event(
request,
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
@@ -396,13 +494,12 @@ async def speech(request: Request, user=Depends(get_verified_user)):
r.raise_for_status()
# Save the streaming content to a file
with open(file_path, 'wb') as f:
async with aiofiles.open(file_path, 'wb') as f:
async for chunk in r.content.iter_chunked(8192):
f.write(chunk)
await f.write(chunk)
with open(file_body_path, 'w') as f:
json.dump(json.loads(body.decode('utf-8')), f)
async with aiofiles.open(file_body_path, 'w') as f:
await f.write(json.dumps(json.loads(body.decode('utf-8'))))
# Return the saved file
return FileResponse(file_path)
@@ -413,7 +510,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
detail = None
if r is not None:
try:
res = await r.json()
res = await r.json(loads=JSONCodec.loads)
if 'error' in res:
detail = f'External: {res["error"]}'
except Exception:
@@ -551,6 +648,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
enable_openai_api, api_base_urls, _, api_configs = await get_openai_runtime_config()
if not enable_openai_api:
request.app.state.OPENAI_MODELS = {}
return {'data': []}
responses = await get_all_models_responses(request, user=user)
@@ -563,19 +661,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
return None
def is_supported_openai_models(model_id):
if any(
name in model_id
for name in [
'babbage',
'dall-e',
'davinci',
'embedding',
'tts',
'whisper',
]
):
return False
return True
return not any(name in model_id for name in _UNSUPPORTED_OPENAI_MODEL_KEYWORDS)
def get_merged_models(model_lists):
log.debug(f'merge_models_lists {model_lists}')
@@ -583,17 +669,18 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
for idx, model_list in enumerate(model_lists):
if model_list is not None and 'error' not in model_list:
base_url = api_base_urls[idx]
hostname = urlparse(base_url).hostname if base_url else None
api_config = api_configs.get(str(idx), api_configs.get(base_url, {}))
for model in model_list:
model_id = model.get('id') or model.get('name')
base_url = api_base_urls[idx]
hostname = urlparse(base_url).hostname if base_url else None
if hostname == 'api.openai.com' and not is_supported_openai_models(model_id):
# Skip unwanted OpenAI models
continue
if model_id and model_id not in models:
api_config = api_configs.get(str(idx), api_configs.get(base_url, {}))
provider = model.get('provider', '')
merged = {
**model,
@@ -642,7 +729,7 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
r = None
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
timeout=_MODEL_LIST_TIMEOUT,
) as session:
try:
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
@@ -666,30 +753,20 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
if r.status != 200:
error_detail = f'HTTP Error: {r.status}'
try:
res = await r.json()
res = await r.json(loads=JSONCodec.loads)
if 'error' in res:
error_detail = f'External Error: {res["error"]}'
except Exception:
pass
raise Exception(error_detail)
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
if 'api.openai.com' in url:
response_data['data'] = [
model
for model in response_data.get('data', [])
if not any(
name in model['id']
for name in [
'babbage',
'dall-e',
'davinci',
'embedding',
'tts',
'whisper',
]
)
if not any(name in model['id'] for name in _UNSUPPORTED_OPENAI_MODEL_KEYWORDS)
]
models = response_data
@@ -728,7 +805,7 @@ async def verify_connection(
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
timeout=_MODEL_LIST_TIMEOUT,
) as session:
try:
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
@@ -756,7 +833,7 @@ async def verify_connection(
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
@@ -782,7 +859,7 @@ async def verify_connection(
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
@@ -1107,6 +1184,9 @@ async def generate_chat_completion(
form_data: dict,
user=Depends(get_verified_user),
):
if not await Config.get('openai.enable'):
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
@@ -1169,8 +1249,7 @@ async def generate_chat_completion(
url, key, api_config = await get_openai_connection(idx)
prefix_id = api_config.get('prefix_id', None)
if prefix_id:
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
# Add user info to the payload if the model is a pipeline
if 'pipeline' in model and model.get('pipeline'):
@@ -1246,6 +1325,10 @@ async def generate_chat_completion(
part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text')
)
is_streaming_request = bool(payload.get('stream', False))
if not is_streaming_request:
payload.pop('stream_options', None)
payload = json.dumps(payload)
r = None
@@ -1262,7 +1345,7 @@ async def generate_chat_completion(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(stream=is_streaming_request),
)
# Check if response is SSE
@@ -1314,7 +1397,7 @@ async def generate_chat_completion(
)
else:
try:
response = await r.json()
response = await r.json(loads=JSONCodec.loads)
except Exception as e:
log.error(e)
response = await r.text()
@@ -1413,20 +1496,20 @@ async def embeddings(request: Request, form_data: dict, user):
data=body,
headers=headers,
cookies=cookies,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r),
stream_wrapper(r, passthrough=True),
status_code=r.status,
headers=_clean_proxy_headers(r.headers),
)
else:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
@@ -1489,6 +1572,7 @@ async def responses(
Routes to the correct upstream backend based on the model field.
"""
payload = form_data.model_dump(exclude_none=True)
is_streaming_request = bool(payload.get('stream', False))
idx = 0
model_id = form_data.model
@@ -1539,20 +1623,20 @@ async def responses(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(stream=is_streaming_request),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r),
stream_wrapper(r, passthrough=True),
status_code=r.status,
headers=_clean_proxy_headers(r.headers),
)
else:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
@@ -1609,6 +1693,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
payload = json.loads(body)
except (json.JSONDecodeError, ValueError):
payload = None
is_streaming_request = bool(payload.get('stream', False)) if isinstance(payload, dict) else False
idx = 0
model_id = payload.get('model') if isinstance(payload, dict) else None
@@ -1660,20 +1745,20 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
timeout=get_client_timeout(stream=is_streaming_request),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r),
stream_wrapper(r, passthrough=True),
status_code=r.status,
headers=_clean_proxy_headers(r.headers),
)
else:
try:
response_data = await r.json()
response_data = await r.json(loads=JSONCodec.loads)
except Exception:
response_data = await r.text()
+27 -25
View File
@@ -1,9 +1,9 @@
import asyncio
import logging
import os
import shutil
from typing import Optional
import aiofiles
import aiohttp
from fastapi import (
APIRouter,
@@ -18,7 +18,7 @@ from fastapi import (
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_FILE_STREAM_CHUNK_SIZE
from open_webui.events import EVENTS, publish_event
from open_webui.models.config import Config
from open_webui.routers.openai import get_all_models_responses
@@ -237,35 +237,37 @@ async def upload_pipeline(
response = None
try:
# Save the uploaded file off the event loop (uploads can be large).
def _save_upload():
with open(file_path, 'wb') as buffer:
shutil.copyfileobj(file.file, buffer)
await asyncio.to_thread(_save_upload)
async with aiofiles.open(file_path, 'wb') as buffer:
while chunk := await file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
await buffer.write(chunk)
url, key = await get_openai_connection(urlIdx)
headers = {'Authorization': f'Bearer {key}'}
async with aiohttp.ClientSession(trust_env=True) as session:
with open(file_path, 'rb') as f:
form_data = aiohttp.FormData()
form_data.add_field(
'file',
f,
filename=filename,
content_type='application/octet-stream',
)
form_data = aiohttp.FormData()
async with session.post(
f'{url}/pipelines/upload',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
async def pipeline_chunks():
async with aiofiles.open(file_path, 'rb') as pipeline_file:
while chunk := await pipeline_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
yield chunk
form_data.add_field(
'file',
pipeline_chunks(),
filename=filename,
content_type='application/octet-stream',
)
async with session.post(
f'{url}/pipelines/upload',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
await publish_event(
request,
@@ -297,7 +299,7 @@ async def upload_pipeline(
finally:
# Ensure the file is deleted after the upload is completed or on failure
if os.path.exists(file_path):
os.remove(file_path)
await asyncio.to_thread(os.remove, file_path)
class AddPipelineForm(BaseModel):
+68 -19
View File
@@ -101,6 +101,7 @@ from open_webui.retrieval.web.ollama import search_ollama_cloud
from open_webui.retrieval.web.perplexity import search_perplexity
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
from open_webui.retrieval.web.searchapi import search_searchapi
from open_webui.retrieval.web.openserp import search_openserp
from open_webui.retrieval.web.searxng import search_searxng
from open_webui.retrieval.web.serpapi import search_serpapi
from open_webui.retrieval.web.serper import search_serper
@@ -127,6 +128,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
TIKTOKEN_DISALLOWED_SPECIAL = ()
##########################################
#
# Utility functions
@@ -262,6 +265,7 @@ RETRIEVAL_CONFIG_KEYS = {
'CHUNK_MIN_SIZE_TARGET': 'rag.chunk_min_size_target',
'CHUNK_OVERLAP': 'rag.chunk_overlap',
'CHUNK_SIZE': 'rag.chunk_size',
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': 'rag.content_extraction.supported_media_mime_types',
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
'DATALAB_MARKER_ADDITIONAL_CONFIG': 'rag.datalab_marker_additional_config',
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
@@ -363,6 +367,7 @@ RETRIEVAL_CONFIG_KEYS = {
'SEARCHAPI_ENGINE': 'web.search.searchapi_engine',
'SEARXNG_LANGUAGE': 'web.search.searxng_language',
'SEARXNG_QUERY_URL': 'web.search.searxng_query_url',
'OPENSERP_BASE_URL': 'web.search.openserp_base_url',
'SERPAPI_API_KEY': 'web.search.serpapi_api_key',
'SERPAPI_ENGINE': 'web.search.serpapi_engine',
'SERPER_API_KEY': 'web.search.serper_api_key',
@@ -626,6 +631,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
'HYBRID_BM25_WEIGHT': config.HYBRID_BM25_WEIGHT,
# Content extraction settings
'CONTENT_EXTRACTION_ENGINE': config.CONTENT_EXTRACTION_ENGINE,
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
'PDF_EXTRACT_IMAGES': config.PDF_EXTRACT_IMAGES,
'PDF_LOADER_MODE': config.PDF_LOADER_MODE,
'DATALAB_MARKER_API_KEY': config.DATALAB_MARKER_API_KEY,
@@ -701,6 +707,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL,
'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE,
'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL,
'YACY_QUERY_URL': config.YACY_QUERY_URL,
'YACY_USERNAME': config.YACY_USERNAME,
'YACY_PASSWORD': config.YACY_PASSWORD,
@@ -779,6 +786,7 @@ class WebConfig(BaseModel):
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: str | None = None
SEARXNG_QUERY_URL: str | None = None
SEARXNG_LANGUAGE: str | None = None
OPENSERP_BASE_URL: str | None = None
YACY_QUERY_URL: str | None = None
YACY_USERNAME: str | None = None
YACY_PASSWORD: str | None = None
@@ -855,6 +863,7 @@ class ConfigForm(BaseModel):
# Content extraction settings
CONTENT_EXTRACTION_ENGINE: str | None = None
CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES: list[str] | None = None
PDF_EXTRACT_IMAGES: bool | None = None
PDF_LOADER_MODE: str | None = None
@@ -967,6 +976,11 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
if form_data.CONTENT_EXTRACTION_ENGINE is not None
else config.CONTENT_EXTRACTION_ENGINE
)
config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES = (
form_data.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES
if form_data.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES is not None
else config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES
)
config.PDF_EXTRACT_IMAGES = (
form_data.PDF_EXTRACT_IMAGES if form_data.PDF_EXTRACT_IMAGES is not None else config.PDF_EXTRACT_IMAGES
)
@@ -1247,6 +1261,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE
config.OPENSERP_BASE_URL = form_data.web.OPENSERP_BASE_URL
config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
config.YACY_USERNAME = form_data.web.YACY_USERNAME
config.YACY_PASSWORD = form_data.web.YACY_PASSWORD
@@ -1326,6 +1341,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'HYBRID_BM25_WEIGHT': config.HYBRID_BM25_WEIGHT,
# Content extraction settings
'CONTENT_EXTRACTION_ENGINE': config.CONTENT_EXTRACTION_ENGINE,
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
'PDF_EXTRACT_IMAGES': config.PDF_EXTRACT_IMAGES,
'PDF_LOADER_MODE': config.PDF_LOADER_MODE,
'DATALAB_MARKER_API_KEY': config.DATALAB_MARKER_API_KEY,
@@ -1398,6 +1414,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL,
'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE,
'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL,
'YACY_QUERY_URL': config.YACY_QUERY_URL,
'YACY_USERNAME': config.YACY_USERNAME,
'YACY_PASSWORD': config.YACY_PASSWORD,
@@ -1562,12 +1579,21 @@ def get_transformers_tokenizer(request: Request, config: RetrievalConfig):
if not os.path.exists(tokenizer_model) and '/' not in tokenizer_model:
tokenizer_model = f'sentence-transformers/{tokenizer_model}'
return AutoTokenizer.from_pretrained(
cache_dir = os.getenv('SENTENCE_TRANSFORMERS_HOME') or os.getenv('HF_HUB_CACHE')
local_files_only = not RAG_EMBEDDING_MODEL_AUTO_UPDATE
tokenizer_key = (tokenizer_model, cache_dir, local_files_only)
cached_tokenizer = getattr(request.app.state, 'transformers_tokenizer', None)
if cached_tokenizer and cached_tokenizer[0] == tokenizer_key:
return cached_tokenizer[1]
tokenizer = AutoTokenizer.from_pretrained(
tokenizer_model,
cache_dir=os.getenv('SENTENCE_TRANSFORMERS_HOME') or os.getenv('HF_HUB_CACHE'),
cache_dir=cache_dir,
trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
local_files_only=not RAG_EMBEDDING_MODEL_AUTO_UPDATE,
local_files_only=local_files_only,
)
request.app.state.transformers_tokenizer = (tokenizer_key, tokenizer)
return tokenizer
tokenizer = getattr(getattr(request.app.state, 'ef', None), 'tokenizer', None)
if tokenizer is not None:
@@ -1582,7 +1608,7 @@ def get_splitter_length_function(
) -> Callable[[str], int]:
if config.TEXT_SPLITTER == 'token':
encoding = tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME))
return lambda text: len(encoding.encode(text, disallowed_special=()))
return lambda text: len(encoding.encode(text, disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL))
if config.TEXT_SPLITTER == 'token_transformers':
tokenizer = get_transformers_tokenizer(request, config)
@@ -1689,6 +1715,7 @@ def save_docs_to_vector_db(
chunk_size=config.CHUNK_SIZE,
chunk_overlap=config.CHUNK_OVERLAP,
add_start_index=True,
disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL,
)
docs = text_splitter.split_documents(docs)
elif config.TEXT_SPLITTER == 'token_transformers':
@@ -1934,7 +1961,7 @@ async def process_file(
]
text_content = ' '.join([doc.page_content for doc in docs])
log.debug(f'text_content: {text_content}')
log.debug('text_content: %s', text_content)
await Files.update_file_data_by_id(
file.id,
{'content': text_content},
@@ -2076,7 +2103,7 @@ async def process_text(
)
]
text_content = form_data.content
log.debug(f'text_content: {text_content}')
log.debug('text_content: %s', text_content)
config = await get_retrieval_config()
result = await run_in_threadpool(save_docs_to_vector_db, request, docs, collection_name, config, user=user)
@@ -2113,7 +2140,7 @@ async def process_web(
config = await get_retrieval_config()
try:
content, docs = await get_content_from_url(request, form_data.url)
log.debug(f'text_content: {content}')
log.debug('text_content: %s', content)
if process:
collection_name = form_data.collection_name
@@ -2209,6 +2236,16 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li
)
else:
raise Exception('No SEARXNG_QUERY_URL found in environment variables')
elif engine == 'openserp':
if config.OPENSERP_BASE_URL:
return await search_openserp(
config.OPENSERP_BASE_URL,
query,
config.WEB_SEARCH_RESULT_COUNT,
config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception('No OPENSERP_BASE_URL found in environment variables')
elif engine == 'yacy':
if config.YACY_QUERY_URL:
return await asyncio.to_thread(
@@ -2604,19 +2641,22 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
if hasattr(result, 'snippet') and result.snippet is not None
]
else:
loader_config = await get_loader_config()
loader = get_web_loader(
urls,
verify_ssl=config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=config.WEB_LOADER_CONCURRENT_REQUESTS,
trust_env=config.WEB_SEARCH_TRUST_ENV,
verify_ssl=loader_config.get('web_loader_ssl_verification'),
requests_per_second=loader_config.get('web_loader_concurrent_requests'),
trust_env=loader_config.get('web_search_trust_env'),
loader_config=loader_config,
)
docs = await loader.aload()
urls = [
doc.metadata.get('source') for doc in docs if doc.metadata.get('source')
] # only keep the urls returned by the loader
url_set = set(urls)
result_items = [
dict(item) for item in result_items if item.link in urls
dict(item) for item in result_items if item.link in url_set
] # only keep the search results that have been loaded
if config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
@@ -2636,7 +2676,8 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
}
else:
# Create a single collection for all documents
collection_name = f'web-search-{calculate_sha256_string("-".join(form_data.queries))}'[:63]
# Bind the ephemeral collection to its owner so filter_accessible_collections can scope it per-user.
collection_name = f'web-search-{user.id}-{calculate_sha256_string("-".join(form_data.queries))}'[:63]
try:
await run_in_threadpool(
@@ -2649,7 +2690,12 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
user=user,
)
except Exception as e:
log.debug(f'error saving docs: {e}')
# Surface the failure instead of returning an unusable collection
log.exception(f'Error saving web search results to vector DB: {e}')
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to embed and store the retrieved web pages. Check the embedding configuration in Admin Settings > Documents.',
)
return {
'status': True,
@@ -2910,21 +2956,24 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo
folder = f'{UPLOAD_DIR}'
try:
# Check if the directory exists
if os.path.exists(folder):
if await asyncio.to_thread(os.path.exists, folder):
# Iterate over all the files and directories in the specified directory
for filename in os.listdir(folder):
for filename in await asyncio.to_thread(os.listdir, folder):
file_path = os.path.join(folder, filename)
try:
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path) # Remove the file or link
elif os.path.isdir(file_path):
shutil.rmtree(file_path) # Remove the directory
if await asyncio.to_thread(os.path.isfile, file_path) or await asyncio.to_thread(
os.path.islink, file_path
):
await asyncio.to_thread(os.unlink, file_path) # Remove the file or link
elif await asyncio.to_thread(os.path.isdir, file_path):
await asyncio.to_thread(shutil.rmtree, file_path) # Remove the directory
except Exception as e:
log.exception(f'Failed to delete {file_path}. Reason: {e}')
else:
log.warning(f'The directory {folder} does not exist')
except Exception as e:
log.exception(f'Failed to process the directory {folder}. Reason: {e}')
await publish_event(
request,
EVENTS.RETRIEVAL_UPLOADS_RESET,
+6
View File
@@ -72,6 +72,8 @@ async def get_skills(
async def get_skill_list(
query: Optional[str] = None,
view_option: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
@@ -86,6 +88,10 @@ async def get_skill_list(
filter['query'] = query
if view_option:
filter['view_option'] = view_option
if order_by:
filter['order_by'] = order_by
if direction:
filter['direction'] = direction
if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL):
groups = await Groups.get_groups_by_member_id(user.id, db=db)
-13
View File
@@ -75,19 +75,6 @@ def config_updates(data: dict, key_map: dict[str, str]) -> dict:
##################################
class ActiveChatsForm(BaseModel):
chat_ids: list[str]
@router.post('/active/chats')
async def check_active_chats(request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)):
"""Check which chat IDs have active tasks."""
from open_webui.tasks import get_active_chat_ids
active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids)
return {'active_chat_ids': active}
@router.get('/config')
async def get_task_config(request: Request, user=Depends(get_verified_user)):
return await get_config_values(TASK_CONFIG_KEYS)
+29 -24
View File
@@ -17,9 +17,9 @@ from open_webui.events import EVENTS, publish_event
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.utils.access_control import has_connection_access
from open_webui.utils.auth import get_verified_user
from open_webui.utils.terminals import get_terminal_server_url
from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token
from starlette.background import BackgroundTask
@@ -49,6 +49,10 @@ def _sanitize_proxy_path(path: str) -> str | None:
# Fail closed: still encoded after the cap means the upstream would decode further into traversal.
if unquote(decoded) != decoded:
return None
# posixpath splits on '/' only, so 'a/..\..\b' survives normpath as one component.
# Upstreams that treat '\' as a separator would resolve it, so reject outright.
if '\\' in decoded:
return None
had_trailing_slash = decoded.endswith('/')
normalized = posixpath.normpath(decoded)
# Remove any leading slashes that would reset the base
@@ -96,11 +100,14 @@ async def proxy_terminal(
if connection is None:
return JSONResponse({'error': f"Terminal server '{server_id}' not found"}, status_code=404)
if not connection.get('enabled', True):
return JSONResponse({'error': 'Terminal server disabled'}, status_code=403)
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
if not await has_connection_access(user, connection, user_group_ids):
return JSONResponse({'error': 'Access denied'}, status_code=403)
base_url = (connection.get('url') or '').rstrip('/')
base_url = get_terminal_server_url(connection)
if not base_url:
return JSONResponse({'error': 'Terminal server URL not configured'}, status_code=503)
@@ -110,11 +117,6 @@ async def proxy_terminal(
target_url = f'{base_url}/{safe_path}'
# Route through orchestrator policy endpoint if policy_id is set
policy_id = connection.get('policy_id')
if policy_id:
target_url = f'{base_url}/p/{policy_id}/{safe_path}'
if request.query_params:
target_url += f'?{request.query_params}'
@@ -133,9 +135,18 @@ async def proxy_terminal(
headers.update(bearer_auth_header(request.state.token.credentials))
elif auth_type == 'system_oauth':
cookies = request.cookies
oauth_token = request.headers.get('x-oauth-access-token', '')
# Resolve the token server-side from the caller's OAuth session; never trust a client header.
oauth_token = None
try:
if request.cookies.get('oauth_session_id', None):
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get('oauth_session_id', None),
)
except Exception as e:
log.error(f'Error getting OAuth token: {e}')
if oauth_token:
headers.update(bearer_auth_header(oauth_token))
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
# auth_type == "none": no Authorization header
content_type = request.headers.get('content-type')
@@ -212,7 +223,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
import asyncio
import json
from open_webui.utils.auth import decode_token, is_valid_token
from open_webui.utils.auth import get_verified_user_by_token
# First-message authentication
try:
@@ -221,14 +232,9 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
if payload.get('type') != 'auth':
await ws.close(code=4001, reason='Expected auth message')
return None
token = payload.get('token', '')
data = decode_token(token)
if data is None or 'id' not in data or not await is_valid_token(data, getattr(ws.app.state, 'redis', None)):
await ws.close(code=4001, reason='Invalid token')
return None
user = await Users.get_user_by_id(data['id'])
user = await get_verified_user_by_token(payload.get('token', ''), getattr(ws.app.state, 'redis', None))
if user is None:
await ws.close(code=4001, reason='User not found')
await ws.close(code=4001, reason='Invalid token')
return None
except (asyncio.TimeoutError, json.JSONDecodeError):
await ws.close(code=4001, reason='Auth timeout or invalid payload')
@@ -245,6 +251,10 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
await ws.close(code=4004, reason='Terminal server not found')
return None
if not connection.get('enabled', True):
await ws.close(code=4003, reason='Terminal server disabled')
return None
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
if not await has_connection_access(user, connection, user_group_ids):
await ws.close(code=4003, reason='Access denied')
@@ -272,7 +282,7 @@ async def ws_terminal(
return
user, connection = result
base_url = (connection.get('url') or '').rstrip('/')
base_url = get_terminal_server_url(connection)
if not base_url:
await ws.close(code=4003, reason='Terminal server URL not configured')
return
@@ -280,8 +290,6 @@ async def ws_terminal(
# Build upstream WebSocket URL (no token in URL)
ws_base = base_url.replace('https://', 'wss://').replace('http://', 'ws://')
# Route through orchestrator policy endpoint if policy_id is set
policy_id = connection.get('policy_id')
upstream_params = {}
# For orchestrator-backed servers, pass user_id
upstream_params['user_id'] = user.id
@@ -292,10 +300,7 @@ async def ws_terminal(
# decode depth) and inject an attacker-chosen user_id ahead of the one appended below.
safe_session_id = urllib.parse.quote(session_id, safe='')
if policy_id:
upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{safe_session_id}'
else:
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
if upstream_params:
upstream_url += f'?{urllib.parse.urlencode(upstream_params)}'
+35 -27
View File
@@ -10,7 +10,7 @@ import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
@@ -72,20 +72,23 @@ async def get_tools(
tools = []
# Local Tools
tools_cache = get_tools_cache(request)
for tool in await Tools.get_tools(defer_content=True, db=db):
tool_module = tools_cache.get(tool.id)
has_user_valves = (
hasattr(tool_module, 'UserValves') if tool_module else (tool.meta.has_user_valves if tool.meta else False)
)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
'has_user_valves': has_user_valves,
}
if ENABLE_PLUGINS:
tools_cache = get_tools_cache(request)
for tool in await Tools.get_tools(defer_content=True, db=db):
tool_module = tools_cache.get(tool.id)
has_user_valves = (
hasattr(tool_module, 'UserValves')
if tool_module
else (tool.meta.has_user_valves if tool.meta else False)
)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
'has_user_valves': has_user_valves,
}
)
)
)
# OpenAPI Tool Servers
server_access_grants = {}
@@ -199,6 +202,9 @@ async def get_tools(
@router.get('/list', response_model=list[ToolAccessResponse])
async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
if not ENABLE_PLUGINS:
return []
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
tools = await Tools.get_tools(defer_content=True, db=db)
else:
@@ -440,20 +446,22 @@ async def get_tools_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
db=db,
)
):
return ToolAccessResponse(
**tools.model_dump(),
write_access=(
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tools.user_id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='tool',
resource_id=tools.id,
permission='write',
db=db,
)
),
write_access = (
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tools.user_id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='tool',
resource_id=tools.id,
permission='write',
db=db,
)
)
data = tools.model_dump()
if not write_access:
# extra='allow' re-admits content from model_dump; source is writer-only
data.pop('content', None)
return ToolAccessResponse(**data, write_access=write_access)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+287 -9
View File
@@ -4,9 +4,11 @@ import base64
import io
import logging
import time
from collections import Counter
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, Response, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
@@ -14,6 +16,8 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_AL
from open_webui.internal.db import get_async_session
from open_webui.models.auths import Auths
from open_webui.models.config import Config
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import (
@@ -40,6 +44,7 @@ from open_webui.utils.auth import (
get_verified_user,
validate_password,
)
from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -196,12 +201,14 @@ class SharingPermissions(BaseModel):
public_skills: bool = False
notes: bool = False
public_notes: bool = True
folders: bool = False
public_chats: bool = False
public_calendars: bool = False
class AccessGrantsPermissions(BaseModel):
allow_users: bool = True
allow_groups: bool = True
class ChatPermissions(BaseModel):
@@ -259,6 +266,129 @@ class UserPermissions(BaseModel):
settings: SettingsPermissions
class UserUsageTotals(BaseModel):
lifetime_tokens: int = 0
input_tokens: int = 0
output_tokens: int = 0
peak_daily_tokens: int = 0
longest_chat_seconds: int = 0
current_streak: int = 0
longest_streak: int = 0
total_chats: int = 0
active_days: int = 0
models_used: int = 0
messages: int = 0
user_messages: int = 0
assistant_messages: int = 0
class UserUsageHeatmapEntry(BaseModel):
date: str
messages: int = 0
chats: int = 0
tokens: int = 0
models: dict[str, int] = Field(default_factory=dict)
class UserUsageModelEntry(BaseModel):
model_id: str
messages: int = 0
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
class UserUsageToolEntry(BaseModel):
name: str
count: int
class UserUsageInsights(BaseModel):
most_used_model: Optional[str] = None
average_tokens_per_chat: float = 0
average_messages_per_active_day: float = 0
user_message_share: float = 0
assistant_message_share: float = 0
class UserUsagePeriod(BaseModel):
start_date: int
end_date: int
days: int
class UserUsageResponse(BaseModel):
totals: UserUsageTotals
heatmap: list[UserUsageHeatmapEntry]
weekly_heatmap: list[UserUsageHeatmapEntry]
cumulative_heatmap: list[UserUsageHeatmapEntry]
insights: UserUsageInsights
top_models: list[UserUsageModelEntry]
top_tools: list[UserUsageToolEntry] = []
period: UserUsagePeriod
def _week_start(date: datetime) -> datetime:
return date - timedelta(days=date.weekday())
def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]:
weeks: dict[str, dict] = {}
for day in heatmap:
week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d')
entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()})
entry['messages'] += day.get('messages', 0)
entry['chats'] += day.get('chats', 0)
entry['tokens'] += day.get('tokens', 0)
entry['models'].update(day.get('models', {}))
return [
{
**weeks[key],
'models': dict(weeks[key]['models']),
}
for key in sorted(weeks)
]
def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]:
totals = {'messages': 0, 'chats': 0, 'tokens': 0}
models: Counter[str] = Counter()
cumulative = []
for day in heatmap:
totals['messages'] += day.get('messages', 0)
totals['chats'] += day.get('chats', 0)
totals['tokens'] += day.get('tokens', 0)
models.update(day.get('models', {}))
cumulative.append(
{
'date': day['date'],
**totals,
'models': dict(models),
}
)
return cumulative
def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]:
longest = 0
current_run = 0
for day in heatmap:
if day.get('messages', 0) > 0:
current_run += 1
longest = max(longest, current_run)
else:
current_run = 0
current = 0
for day in reversed(heatmap):
if day.get('messages', 0) <= 0:
break
current += 1
return {'current': current, 'longest': longest}
@router.get('/default/permissions', response_model=UserPermissions)
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
user_permissions = await Config.get('user.permissions')
@@ -348,6 +478,23 @@ async def update_user_settings_by_session_user(
# If the user is not an admin and does not have permission to use tool servers, remove the key
updated_user_settings['ui'].pop('toolServers', None)
ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
if (
user.role != 'admin'
and (
'notifications' in updated_user_settings
or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
)
and not await has_permission(
user.id,
'features.webhooks',
await Config.get('user.permissions'),
)
):
updated_user_settings.pop('notifications', None)
if isinstance(ui_notifications, dict):
ui_notifications.pop('webhook_url', None)
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
if user:
await publish_event(
@@ -428,6 +575,52 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
return user.info
class UserVariablesForm(BaseModel):
variables: dict = Field(default_factory=dict)
class UserVariablesResponse(BaseModel):
variables: dict[str, str] = Field(default_factory=dict)
############################
# GetUserVariablesBySessionUser
############################
@router.get('/user/variables', response_model=UserVariablesResponse)
async def get_user_variables_by_session_user(user=Depends(get_verified_user)):
return UserVariablesResponse(variables=normalize_user_variables(user.variables))
############################
# UpdateUserVariablesBySessionUser
############################
@router.post('/user/variables/update', response_model=UserVariablesResponse)
async def update_user_variables_by_session_user(
form_data: UserVariablesForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
try:
variables = validate_user_variables(form_data.variables)
except ChatVariablesError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db)
if not updated:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
return UserVariablesResponse(variables=variables)
############################
# UpdateUserInfoBySessionUser
############################
@@ -455,6 +648,86 @@ async def update_user_info_by_session_user( # PATCH-style merge
return updated.info
############################
# GetUserUsageBySessionUser
############################
@router.get('/usage', response_model=UserUsageResponse)
async def get_user_usage_by_session_user(
days: Optional[int] = Query(None, ge=7, le=732),
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
now = int(time.time())
period_end = end_date or now
if start_date is not None:
period_start = start_date
elif days is not None:
period_start = period_end - ((days - 1) * 86400)
else:
period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400))
if period_start > period_end:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='start_date must be before end_date',
)
period_days = max(1, int((period_end - period_start) / 86400) + 1)
lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db)
period_summary = await ChatMessages.get_user_usage_summary(
user.id, period_start, period_end, timezone=user.timezone, db=db
)
chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db)
heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db)
top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db)
top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db)
streaks = _calculate_streaks(heatmap)
total_messages = period_summary.get('messages', 0)
total_chats = chat_stats.get('total_chats', 0)
active_days = period_summary.get('active_days', 0)
assistant_messages = period_summary.get('assistant_messages', 0)
user_messages = period_summary.get('user_messages', 0)
return UserUsageResponse(
totals=UserUsageTotals(
lifetime_tokens=lifetime_summary.get('total_tokens', 0),
input_tokens=lifetime_summary.get('input_tokens', 0),
output_tokens=lifetime_summary.get('output_tokens', 0),
peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0),
longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0),
current_streak=streaks['current'],
longest_streak=streaks['longest'],
total_chats=total_chats,
active_days=active_days,
models_used=lifetime_summary.get('models_used', 0),
messages=total_messages,
user_messages=user_messages,
assistant_messages=assistant_messages,
),
heatmap=heatmap,
weekly_heatmap=_build_weekly_heatmap(heatmap),
cumulative_heatmap=_build_cumulative_heatmap(heatmap),
insights=UserUsageInsights(
most_used_model=top_models[0]['model_id'] if top_models else None,
average_tokens_per_chat=(
round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0
),
average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0,
user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0,
assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0,
),
top_models=top_models,
top_tools=top_tools,
period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days),
)
############################
# GetUserById
############################
@@ -796,36 +1069,41 @@ async def get_user_preview(
user_group_ids = {g.id for g in user_groups}
all_models = await Models.get_all_models(db=db)
accessible_model_ids = await AccessGrants.get_accessible_resource_ids(
active_models = [m for m in all_models if m.is_active]
owned_model_ids = {m.id for m in active_models if m.user_id == user_id}
granted_model_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='model',
resource_ids=[m.id for m in all_models],
resource_ids=[m.id for m in active_models if m.user_id != user_id],
permission='read',
user_group_ids=user_group_ids,
db=db,
)
accessible_model_ids = owned_model_ids | granted_model_ids
all_knowledge = await Knowledges.get_knowledge_bases(db=db)
accessible_knowledge_ids = await AccessGrants.get_accessible_resource_ids(
owned_knowledge_ids = {k.id for k in all_knowledge if k.user_id == user_id}
granted_knowledge_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='knowledge',
resource_ids=[k.id for k in all_knowledge],
resource_ids=[k.id for k in all_knowledge if k.user_id != user_id],
permission='read',
user_group_ids=user_group_ids,
db=db,
)
accessible_knowledge_ids = owned_knowledge_ids | granted_knowledge_ids
all_tools = await Tools.get_tools(defer_content=True, db=db)
accessible_tool_ids = await AccessGrants.get_accessible_resource_ids(
owned_tool_ids = {t.id for t in all_tools if t.user_id == user_id}
granted_tool_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='tool',
resource_ids=[t.id for t in all_tools],
resource_ids=[t.id for t in all_tools if t.user_id != user_id],
permission='read',
user_group_ids=user_group_ids,
db=db,
)
active_models = [m for m in all_models if m.is_active]
accessible_tool_ids = owned_tool_ids | granted_tool_ids
return {
'user': {'id': target_user.id, 'name': target_user.name},
+151 -85
View File
@@ -5,7 +5,6 @@ import logging
import random
import sys
import time
from typing import Dict
import pycrdt as Y
import socketio
@@ -16,7 +15,6 @@ from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT,
GLOBAL_LOG_LEVEL,
REDIS_KEY_PREFIX,
VERSION,
WEBSOCKET_EVENT_CALLER_TIMEOUT,
WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_CLUSTER,
@@ -33,18 +31,21 @@ from open_webui.env import (
from open_webui.models.access_grants import AccessGrants
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
from open_webui.models.folders import Folders
from open_webui.models.notes import Notes, NoteUpdateForm
from open_webui.models.users import UserNameResponse, Users
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
from open_webui.tasks import create_task, stop_item_tasks
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import decode_token, is_valid_token
from open_webui.utils.auth import get_verified_user_by_token
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.json_codec import SOCKETIO_JSON
from open_webui.utils.misc import get_output_text
from open_webui.utils.redis import (
build_sentinel_url,
get_redis_connection,
get_sentinels_from_env,
)
from redis import asyncio as aioredis
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@@ -57,6 +58,12 @@ REDIS = None
# Configure CORS for Socket.IO
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
def get_room_sid_map(manager, namespace: str, room: str):
"""Return this process's Socket.IO sid map for a room, without copying it."""
return manager.rooms.get(namespace, {}).get(room)
if WEBSOCKET_MANAGER == 'redis':
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
ws_redis_url = (
@@ -64,10 +71,11 @@ if WEBSOCKET_MANAGER == 'redis':
if sentinel_hosts
else WEBSOCKET_REDIS_URL
)
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS, json=SOCKETIO_JSON)
sio = socketio.AsyncServer(
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode='asgi',
json=SOCKETIO_JSON,
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
always_connect=True,
@@ -81,6 +89,7 @@ else:
sio = socketio.AsyncServer(
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode='asgi',
json=SOCKETIO_JSON,
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
always_connect=True,
@@ -166,25 +175,31 @@ YDOC_MANAGER = YdocManager(
async def periodic_session_pool_cleanup():
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
if not session_aquire_func():
log.debug('Session cleanup lock held by another node. Skipping.')
return
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
while True:
if not session_aquire_func():
log.debug('Session cleanup lock held by another node. Retrying.')
await asyncio.sleep(retry_delay)
continue
try:
while True:
if not session_renew_func():
log.error('Unable to renew session cleanup lock. Exiting.')
return
try:
while True:
if not session_renew_func():
log.warning('Unable to renew session cleanup lock. Retrying cleanup ownership.')
break
now = int(time.time())
for sid in list(SESSION_POOL.keys()):
entry = SESSION_POOL.get(sid)
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
del SESSION_POOL[sid]
await asyncio.sleep(SESSION_POOL_TIMEOUT)
finally:
session_release_func()
now = int(time.time())
for sid in list(SESSION_POOL.keys()):
entry = SESSION_POOL.get(sid)
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
try:
del SESSION_POOL[sid]
except KeyError:
pass
await asyncio.sleep(SESSION_POOL_TIMEOUT)
finally:
session_release_func()
async def periodic_usage_pool_cleanup():
@@ -205,17 +220,19 @@ async def periodic_usage_pool_cleanup():
try:
while True:
if not renew_func():
log.error(f'Unable to renew cleanup lock. Exiting usage pool cleanup.')
log.error('Unable to renew cleanup lock. Exiting usage pool cleanup.')
raise Exception('Unable to renew usage pool cleanup lock.')
now = int(time.time())
send_usage = False
for model_id, connections in list(USAGE_POOL.items()):
# Creating a list of sids to remove if they have timed out
expired_sids = [
sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION
]
if connections and not expired_sids:
continue
for sid in expired_sids:
del connections[sid]
@@ -224,8 +241,6 @@ async def periodic_usage_pool_cleanup():
del USAGE_POOL[model_id]
else:
USAGE_POOL[model_id] = connections
send_usage = True
await asyncio.sleep(TIMEOUT_DURATION)
finally:
release_func()
@@ -252,24 +267,21 @@ def get_user_id_from_session_pool(sid):
def get_session_ids_from_room(room):
"""Get all session IDs from a specific room."""
active_session_ids = sio.manager.get_participants(
namespace='/',
room=room,
)
return [session_id[0] for session_id in active_session_ids]
members = get_room_sid_map(sio.manager, '/', room)
return list(members) if members else []
def get_user_ids_from_room(room):
active_session_ids = get_session_ids_from_room(room)
# Single pool lookup per session (each .get is a Redis round trip
# when the session pool is Redis-backed).
active_user_ids = list(
set(
[
SESSION_POOL.get(session_id)['id']
for session_id in active_session_ids
if SESSION_POOL.get(session_id) is not None
]
)
{
entry['id']
for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids)
if entry is not None
}
)
return active_user_ids
@@ -333,7 +345,7 @@ async def usage(sid, data):
# Store the new usage data and task
USAGE_POOL[model_id] = {
**(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}),
**(USAGE_POOL.get(model_id) or {}),
sid: {'updated_at': current_time},
}
@@ -345,13 +357,10 @@ async def connect(sid, environ, auth):
scope = (environ or {}).get('asgi.scope') or {}
fastapi_app = scope.get('app')
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
data = decode_token(auth['token'])
if data is not None and 'id' in data and await is_valid_token(data, redis):
user = await Users.get_user_by_id(data['id'])
user = await get_verified_user_by_token(auth['token'], redis)
if user:
SESSION_POOL[sid] = {
socket_user = {
**user.model_dump(
exclude=[
'profile_image_url',
@@ -363,6 +372,8 @@ async def connect(sid, environ, auth):
),
'last_seen_at': int(time.time()),
}
SESSION_POOL[sid] = socket_user
await sio.save_session(sid, {'user': socket_user})
await sio.enter_room(sid, f'user:{user.id}')
@@ -376,15 +387,11 @@ async def user_join(sid, data):
scope = environ.get('asgi.scope') or {}
fastapi_app = scope.get('app')
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
token_data = decode_token(auth['token'])
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
return
user = await Users.get_user_by_id(token_data['id'])
user = await get_verified_user_by_token(auth['token'], redis)
if not user:
return
SESSION_POOL[sid] = {
socket_user = {
**user.model_dump(
exclude=[
'profile_image_url',
@@ -397,6 +404,8 @@ async def user_join(sid, data):
'last_seen_at': int(time.time()),
}
SESSION_POOL[sid] = socket_user
await sio.save_session(sid, {'user': socket_user})
await sio.enter_room(sid, f'user:{user.id}')
# Join all the channels only if user has channels permission
@@ -427,11 +436,7 @@ async def join_channel(sid, data):
scope = environ.get('asgi.scope') or {}
fastapi_app = scope.get('app')
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
data = decode_token(auth['token'])
if data is None or 'id' not in data or not await is_valid_token(data, redis):
return
user = await Users.get_user_by_id(data['id'])
user = await get_verified_user_by_token(auth['token'], redis)
if not user:
return
@@ -453,11 +458,7 @@ async def join_note(sid, data):
scope = environ.get('asgi.scope') or {}
fastapi_app = scope.get('app')
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
token_data = decode_token(auth['token'])
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
return
user = await Users.get_user_by_id(token_data['id'])
user = await get_verified_user_by_token(auth['token'], redis)
if not user:
return
@@ -486,13 +487,7 @@ async def join_note(sid, data):
@sio.on('events:channel')
async def channel_events(sid, data):
room = f'channel:{data["channel_id"]}'
participants = sio.manager.get_participants(
namespace='/',
room=room,
)
sids = [sid for sid, _ in participants]
if sid not in sids:
if sid not in (get_room_sid_map(sio.manager, '/', room) or {}):
return
event_data = data['data']
@@ -518,9 +513,35 @@ async def channel_events(sid, data):
await Channels.update_member_last_read_at(data['channel_id'], user['id'])
async def get_folder_unread_counts(user_id: str) -> dict[str, int]:
folder_list = await Folders.get_folders_by_user_id(user_id)
parent_by_id = {folder.id: folder.parent_id for folder in folder_list}
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()))
for unread_folder_id, unread_count in direct_unread_counts.items():
current_id = unread_folder_id
seen = set()
while current_id and current_id not in seen:
seen.add(current_id)
if current_id in unread_counts:
unread_counts[current_id] += unread_count
current_id = parent_by_id.get(current_id)
return unread_counts
@sio.on('events:chat')
async def chat_events(sid, data):
user = SESSION_POOL.get(sid)
try:
session = await sio.get_session(sid)
user = session.get('user')
except KeyError:
user = None
if not user:
user = SESSION_POOL.get(sid)
if not user:
return
@@ -528,7 +549,34 @@ async def chat_events(sid, data):
event_type = event_data.get('type')
if event_type == 'last_read_at':
await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
read_update = await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
if not read_update:
return
last_read_at, was_unread = read_update
response_data = {
'chat_id': data['chat_id'],
'last_read_at': last_read_at,
}
if was_unread:
response_data['folder_unread_counts'] = await get_folder_unread_counts(user['id'])
await sio.emit(
'events',
{
'chat_id': data['chat_id'],
'data': {
'type': 'chat:list',
'data': response_data,
},
},
room=f'user:{user["id"]}',
)
try:
from open_webui.utils.timers import cancel_timers_for_chat
await cancel_timers_for_chat(data['chat_id'], 'chat.read', user['id'])
except Exception:
log.exception('Failed to cancel chat.read timers for chat %s', data.get('chat_id'))
def normalize_document_id(document_id: str) -> str:
@@ -829,7 +877,6 @@ async def yjs_awareness_update(sid, data):
@sio.event
async def disconnect(sid, reason=None):
if sid in SESSION_POOL:
user = SESSION_POOL[sid]
del SESSION_POOL[sid]
# Clean up USAGE_POOL entries for this session
@@ -860,19 +907,20 @@ async def _make_channel_emitter(request_info):
state = {'last_emit_at': 0.0}
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
async def _emit_channel_update(content: str, done: bool = False):
async def _emit_channel_update(content: str, done: bool = False, output: list | None = None):
from open_webui.models.messages import MessageForm, Messages
msg = await Messages.get_message_by_id(message_id)
if not msg or msg.channel_id != channel_id:
return
update_form = MessageForm(content=content)
update_form = MessageForm(content=content, data={'output': output} if output else None)
if done:
# Merge done flag into existing meta (preserve model_id etc.)
existing_meta = msg.meta or {}
update_form = MessageForm(
content=content,
data={'output': output} if output else None,
meta={**existing_meta, 'done': True},
)
@@ -897,16 +945,17 @@ async def _make_channel_emitter(request_info):
if event_type == 'chat:completion':
data = event_data.get('data', {})
content = data.get('content', '')
output = data.get('output')
content = data.get('content') or get_output_text(output)
done = data.get('done', False)
if not content and not done:
if not content and not output and not done:
return
now = __import__('time').time()
if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL:
state['last_emit_at'] = now
await _emit_channel_update(content, done)
await _emit_channel_update(content, done, output if isinstance(output, list) else None)
elif event_type == 'chat:message:error':
error = event_data.get('data', {}).get('error', {})
@@ -925,18 +974,27 @@ async def get_event_emitter(request_info, update_db=True):
user_id = request_info['user_id']
chat_id = request_info['chat_id']
message_id = request_info['message_id']
internal = request_info.get('internal') is True
save_to_chat = update_db and message_id and is_saved_chat_id(chat_id)
await sio.emit(
'events',
{
'chat_id': chat_id,
'message_id': message_id,
'data': event_data,
},
room=f'user:{user_id}',
)
if internal and event_data.get('type') == 'notification':
return
if update_db and message_id and not (request_info.get('chat_id') or '').startswith('local:'):
room = f'user:{user_id}'
# Local rooms are authoritative; Redis may have listeners on another instance.
if WEBSOCKET_MANAGER == 'redis' or room in sio.manager.rooms.get('/', {}):
await sio.emit(
'events',
{
'chat_id': chat_id,
'message_id': message_id,
**({'internal': True} if internal else {}),
'data': event_data,
},
room=room,
)
if save_to_chat:
event_type = event_data.get('type')
if event_type == 'status':
@@ -992,6 +1050,7 @@ async def get_event_emitter(request_info, update_db=True):
{
'embeds': embeds,
},
touch=False,
)
elif event_type == 'files':
@@ -1009,6 +1068,7 @@ async def get_event_emitter(request_info, update_db=True):
{
'files': files,
},
touch=False,
)
elif event_type in ('source', 'citation'):
@@ -1028,6 +1088,7 @@ async def get_event_emitter(request_info, update_db=True):
{
'sources': sources,
},
touch=False,
)
if 'user_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
@@ -1059,6 +1120,11 @@ async def get_event_call(request_info):
)
except TimeoutError:
log.warning(f'Event caller timed out for session {session_id}')
if SESSION_POOL.get(session_id) == session:
try:
del SESSION_POOL[session_id]
except KeyError:
pass
return {'error': 'Event call timed out. The browser tab may be inactive or closed.'}
if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
+33 -17
View File
@@ -3,12 +3,12 @@
from __future__ import annotations
import hashlib
import json
import uuid
import pycrdt as Y
from open_webui.utils.redis import get_redis_connection
from open_webui.env import REDIS_KEY_PREFIX
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.redis import get_redis_connection
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
@@ -16,6 +16,19 @@ YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
class RedisLock:
"""Distributed lock backed by a Redis SET with NX/EX semantics."""
_RENEW_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
end
return 0
"""
_RELEASE_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
"""
def __init__(
self,
redis_url,
@@ -41,13 +54,10 @@ class RedisLock:
return self.lock_obtained
def renew_lock(self):
# xx=True will only set this key if it _has_ already been set
return self.redis.set(self.lock_name, self.lock_id, xx=True, ex=self.timeout_secs)
return bool(self.redis.eval(self._RENEW_SCRIPT, 1, self.lock_name, self.lock_id, self.timeout_secs))
def release_lock(self):
lock_value = self.redis.get(self.lock_name)
if lock_value and lock_value == self.lock_id:
self.redis.delete(self.lock_name)
self.redis.eval(self._RELEASE_SCRIPT, 1, self.lock_name, self.lock_id)
class RedisDict:
@@ -65,14 +75,14 @@ class RedisDict:
)
def __setitem__(self, key, value):
serialized_value = json.dumps(value)
serialized_value = JSONCodec.dumps(value)
self.redis.hset(self.name, key, serialized_value)
def __getitem__(self, key):
value = self.redis.hget(self.name, key)
if value is None:
raise KeyError(key)
return json.loads(value)
return JSONCodec.loads(value)
def __delitem__(self, key):
result = self.redis.hdel(self.name, key)
@@ -89,10 +99,10 @@ class RedisDict:
return self.redis.hkeys(self.name)
def values(self):
return [json.loads(v) for v in self.redis.hvals(self.name)]
return [JSONCodec.loads(v) for v in self.redis.hvals(self.name)]
def items(self):
return [(k, json.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
return [(k, JSONCodec.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
def set(self, mapping: dict):
if not mapping:
@@ -101,13 +111,19 @@ class RedisDict:
return
# Serialize values once — reused for both the fingerprint and the write.
serialized = {k: json.dumps(v) for k, v in mapping.items()}
serialized = {k: JSONCodec.dumps(v) for k, v in mapping.items()}
digest = hashlib.sha256()
for key in sorted(serialized):
digest.update(key.encode())
digest.update(b'\0')
digest.update(serialized[key].encode())
digest.update(b'\0')
signature = digest.hexdigest()
# Skip the write when the prepared mapping is identical to the last one
# this process wrote. The check is per-instance (not distributed), but
# still eliminates the majority of redundant writes because each pod
# typically produces the same model list on consecutive refreshes.
signature = hashlib.sha256(json.dumps(serialized, sort_keys=True).encode()).hexdigest()
if signature == self._last_signature:
return
@@ -166,7 +182,7 @@ class YdocManager:
document_id = document_id.replace(':', '_')
if self._redis:
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
await self._redis.rpush(redis_key, json.dumps(list(update)))
await self._redis.rpush(redis_key, JSONCodec.dumps(list(update)))
list_len = await self._redis.llen(redis_key)
if list_len >= self.COMPACTION_THRESHOLD:
await self._compact_updates_redis(document_id)
@@ -186,8 +202,8 @@ class YdocManager:
mid = len(all_updates) // 2
ydoc = Y.Doc()
for raw in all_updates[:mid]:
ydoc.apply_update(bytes(json.loads(raw)))
snapshot = json.dumps(list(ydoc.get_update()))
ydoc.apply_update(bytes(JSONCodec.loads(raw)))
snapshot = JSONCodec.dumps(list(ydoc.get_update()))
pipe = self._redis.pipeline()
pipe.delete(redis_key)
pipe.rpush(redis_key, snapshot, *all_updates[mid:])
@@ -210,7 +226,7 @@ class YdocManager:
if self._redis:
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
updates = await self._redis.lrange(redis_key, 0, -1)
return [bytes(json.loads(update)) for update in updates]
return [bytes(JSONCodec.loads(update)) for update in updates]
else:
return self._updates.get(document_id, [])
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

+7 -18
View File
@@ -2,10 +2,8 @@
import asyncio
import json
import logging
from typing import Dict, List, Optional
from uuid import uuid4
from fastapi import Request
from redis.asyncio import Redis
from open_webui.env import REDIS_KEY_PREFIX
@@ -13,7 +11,7 @@ from open_webui.env import REDIS_KEY_PREFIX
log = logging.getLogger(__name__)
# A dictionary to keep track of active tasks
tasks: Dict[str, asyncio.Task] = {}
tasks: dict[str, asyncio.Task] = {}
item_tasks = {}
@@ -46,7 +44,7 @@ async def redis_task_command_listener(app):
### ------------------------------
async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
async def redis_save_task(redis: Redis, task_id: str, item_id: str | None):
pipe = redis.pipeline()
pipe.hset(REDIS_TASKS_KEY, task_id, item_id or '')
if item_id:
@@ -54,7 +52,7 @@ async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
await pipe.execute()
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]):
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None):
pipe = redis.pipeline()
pipe.hdel(REDIS_TASKS_KEY, task_id)
if item_id:
@@ -67,11 +65,11 @@ async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str])
await pipe.execute()
async def redis_list_tasks(redis: Redis) -> List[str]:
async def redis_list_tasks(redis: Redis) -> list[str]:
return list(await redis.hkeys(REDIS_TASKS_KEY))
async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]:
async def redis_list_item_tasks(redis: Redis, item_id: str) -> list[str]:
return list(await redis.smembers(f'{REDIS_ITEM_TASKS_KEY}:{item_id}'))
@@ -101,11 +99,11 @@ async def cleanup_task(redis, task_id: str, id=None):
item_tasks.pop(id, None)
async def create_task(redis, coroutine, id=None):
async def create_task(redis, coroutine, id=None, task_id=None):
"""
Create a new asyncio task and add it to the global task dictionary.
"""
task_id = str(uuid4()) # Generate a unique ID for the task
task_id = task_id or str(uuid4()) # Generate a unique ID for the task
task = asyncio.create_task(coroutine) # Create the task
# Add a done callback for cleanup
@@ -199,12 +197,3 @@ async def has_active_tasks(redis, chat_id: str) -> bool:
"""Check if a chat has any active tasks."""
task_ids = await list_task_ids_by_item_id(redis, chat_id)
return len(task_ids) > 0
async def get_active_chat_ids(redis, chat_ids: List[str]) -> List[str]:
"""Filter a list of chat_ids to only those with active tasks."""
active = []
for chat_id in chat_ids:
if await has_active_tasks(redis, chat_id):
active.append(chat_id)
return active
File diff suppressed because it is too large Load Diff
+82 -32
View File
@@ -7,24 +7,58 @@ for AI models to interact with knowledge bases using commands they already know.
Re-exported through builtin.py for consistent imports.
"""
import contextvars
import json
import logging
import re
import shlex
import time
from contextlib import contextmanager
from typing import Optional
import regex
from fastapi import Request
from open_webui.env import (
KB_EXEC_MAX_GREP_FILES,
KB_EXEC_MAX_OUTPUT_CHARS,
KNOWLEDGE_GREP_MAX_MATCHES,
)
log = logging.getLogger(__name__)
# Limits
MAX_CAT_CHARS = 100_000
DEFAULT_CAT_CHARS = 10_000
MAX_GREP_FILES = 200
DEFAULT_HEAD_LINES = 10
DEFAULT_TAIL_LINES = 10
MAX_GREP_MATCHES = 50
# Matching time allowed per tool call. Backtracking cost is exponential in the length of the
# matched text, so capping the pattern or the line does not bound it.
MATCH_BUDGET_SECONDS = 2.0
class MatchBudgetExceeded(Exception):
"""A tool call spent its whole matching budget, so the caller reports it."""
class MatchBudget:
"""Matching time remaining, counted only inside search() so awaits do not consume it."""
def __init__(self):
self.remaining = MATCH_BUDGET_SECONDS
# Scoped to the running task, so one budget covers every matcher a command builds without
# threading it through each handler.
_active_budget: contextvars.ContextVar[MatchBudget | None] = contextvars.ContextVar('kb_match_budget', default=None)
@contextmanager
def match_budget():
"""Bound the matching time of one tool call rather than of each search it runs."""
token = _active_budget.set(MatchBudget())
try:
yield
finally:
_active_budget.reset(token)
# =============================================================================
@@ -33,9 +67,9 @@ MAX_GREP_MATCHES = 50
def is_regex_pattern(pattern: str) -> bool:
"""Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...])."""
"""Detect if a pattern looks like regex (|, .*, .+, \d, \w, \s, [...])."""
return (
'\|' in pattern
'|' in pattern
or '.*' in pattern
or '.+' in pattern
or '.?' in pattern
@@ -59,11 +93,26 @@ def build_matcher(pattern: str, case_insensitive: bool = False, use_regex: bool
if use_regex:
normalized = normalize_regex(pattern)
try:
re_flags = re.IGNORECASE if case_insensitive else 0
compiled = re.compile(normalized, re_flags)
except re.error as e:
re_flags = regex.IGNORECASE if case_insensitive else 0
compiled = regex.compile(normalized, re_flags)
except regex.error as e:
return None, f'Invalid regex: {e}'
return (lambda line: bool(compiled.search(line))), None
budget = _active_budget.get() or MatchBudget()
def matches(line: str) -> bool:
started = time.monotonic()
try:
# A negative timeout disables it, so an exhausted budget must not reach search().
if budget.remaining <= 0:
raise TimeoutError
return bool(compiled.search(line, timeout=budget.remaining))
except TimeoutError:
raise MatchBudgetExceeded(f'Search exceeded {MATCH_BUDGET_SECONDS:g}s, narrow the pattern') from None
finally:
budget.remaining -= time.monotonic() - started
return matches, None
else:
sp = pattern.lower() if case_insensitive else pattern
return (lambda line: sp in (line.lower() if case_insensitive else line)), None
@@ -579,21 +628,10 @@ async def _kb_cat(args: list[str], flags: set[str], user: dict, model_knowledge:
return resolved['error']
content = resolved['content']
show_numbers = 'n' in flags
if len(content) > MAX_CAT_CHARS:
content = content[:MAX_CAT_CHARS]
truncated = True
else:
truncated = False
if show_numbers:
if 'n' in flags:
lines = content.split('\n')
content = '\n'.join(f'{i}: {line}' for i, line in enumerate(lines, 1))
if truncated:
content += f'\n[truncated at {MAX_CAT_CHARS:,} chars — use head/tail/sed/grep to navigate]'
return content
@@ -686,12 +724,16 @@ async def _kb_grep(
# Grep on piped input
if piped_input is not None:
lines = piped_input.split('\\n')
lines = piped_input.split('\n')
matched = []
for i, line in enumerate(lines, 1):
if _matches(line):
matched.append(f'{i}: {line}')
return '\\n'.join(matched) if matched else f'No matches for "{pattern}"'
if count_only:
return str(len(matched))
if filenames_only:
return '(standard input)' if matched else f'No matches for "{pattern}"'
return '\n'.join(matched) if matched else f'No matches for "{pattern}"'
# Single file grep
if file_ref and not dir_scope:
@@ -702,7 +744,7 @@ async def _kb_grep(
elif 'error' in resolved:
return resolved['error']
else:
lines = resolved['content'].split('\\n')
lines = resolved['content'].split('\n')
matched = []
for i, line in enumerate(lines, 1):
if _matches(line):
@@ -715,7 +757,7 @@ async def _kb_grep(
if not matched:
return f'No matches for "{pattern}" in {resolved["filename"]}'
return '\\n'.join(matched)
return '\n'.join(matched)
# Cross-file grep (optionally scoped to directory)
accessible = await _get_accessible_files(user, model_knowledge)
@@ -738,7 +780,7 @@ async def _kb_grep(
if ext_filter:
accessible = [f for f in accessible if f['filename'].endswith(f'.{ext_filter}')]
if len(accessible) > MAX_GREP_FILES:
if len(accessible) > KB_EXEC_MAX_GREP_FILES:
return f'Too many files ({len(accessible)}). Scope your search: grep "{pattern}" docs/ or grep "{pattern}" *.py'
from open_webui.models.files import Files
@@ -770,7 +812,7 @@ async def _kb_grep(
if not count_only and not filenames_only:
for line_num, line_text in file_matches:
if len(results) < MAX_GREP_MATCHES:
if len(results) < KNOWLEDGE_GREP_MAX_MATCHES:
results.append(f'{file_info["id"]} {file_info["filename"]}:{line_num}: {line_text.rstrip()}')
if count_only:
@@ -789,8 +831,8 @@ async def _kb_grep(
return f'No matches for "{pattern}" across {len(accessible)} files'
output = '\n'.join(results)
if total_matches > MAX_GREP_MATCHES:
output += f'\n[showing {MAX_GREP_MATCHES} of {total_matches} matches]'
if total_matches > KNOWLEDGE_GREP_MAX_MATCHES:
output += f'\n[showing {KNOWLEDGE_GREP_MAX_MATCHES} of {total_matches} matches]'
return output
@@ -1127,7 +1169,15 @@ async def kb_exec(
if not segments:
return 'Could not parse command. Run kb_exec("ls") to start.'
return await _execute_pipeline(segments, __user__, __model_knowledge__)
# One budget for the whole command: a per-search budget would multiply by segment count.
with match_budget():
output = await _execute_pipeline(segments, __user__, __model_knowledge__)
if len(output) > KB_EXEC_MAX_OUTPUT_CHARS:
output = output[:KB_EXEC_MAX_OUTPUT_CHARS] + (
f'\n[output truncated at {KB_EXEC_MAX_OUTPUT_CHARS:,} chars'
' — narrow the command with a path, glob, head/tail/sed or grep]'
)
return output
except Exception as e:
log.exception(f'kb_exec error: {e}')
return f'Error: {e}'
@@ -3,9 +3,11 @@ from typing import Any
from open_webui.config import DEFAULT_USER_PERMISSIONS
from open_webui.models.access_grants import (
has_anyone_read_access_grant,
has_public_read_access_grant,
has_public_write_access_grant,
has_user_access_grant,
strip_anyone_access_grants,
strip_user_access_grants,
)
from open_webui.models.groups import Groups
@@ -161,10 +163,16 @@ async def has_connection_access(
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
return True
access_grants = (connection.get('config') or {}).get('access_grants', [])
if not access_grants:
# No grants configured → private, admin-only: admins must keep access
# to connections only they can configure, even when they do not bypass
# access control globally.
return user.role == 'admin'
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
access_grants = (connection.get('config') or {}).get('access_grants', [])
return await has_access(user.id, 'read', access_grants, user_group_ids)
@@ -215,13 +223,31 @@ async def filter_allowed_access_grants(
user_role: str,
access_grants: list,
public_permission_key: str,
anyone_permission_key: str | None = None,
db: AsyncSession | None = None,
) -> list:
"""
Checks if the user has the required permissions to grant access to a resource.
Returns the filtered list of access grants if permissions are missing.
"""
if user_role == 'admin' or not access_grants:
if not access_grants:
return access_grants
if has_anyone_read_access_grant(access_grants) and (
not anyone_permission_key
or (
user_role != 'admin'
and not await has_permission(
user_id,
anyone_permission_key,
default_permissions,
db=db,
)
)
):
access_grants = strip_anyone_access_grants(access_grants)
if user_role == 'admin':
return access_grants
# Check if user can share publicly
@@ -253,6 +279,22 @@ async def filter_allowed_access_grants(
):
access_grants = strip_user_access_grants(access_grants)
if any(
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group'
for grant in access_grants
) and not await has_permission(
user_id,
'access_grants.allow_groups',
default_permissions,
db=db,
):
access_grants = [
grant
for grant in access_grants
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
!= 'group'
]
return access_grants
@@ -260,6 +302,7 @@ async def has_base_model_access(
user_id: str,
model_info,
*,
user_role: str | None = None,
user_group_ids: set[str] | None = None,
db=None,
) -> bool:
@@ -267,9 +310,11 @@ async def has_base_model_access(
Walk the ``base_model_id`` chain and verify the caller has read access
at every hop.
Returns ``True`` when access is granted (or the chain ends at a raw
provider model that has no per-model ACL). Returns ``False`` the
moment a registered base model denies access.
A base model without a ``model`` table row is admin-only, matching how
unregistered models are treated for direct use (``get_filtered_models``
hides them from non-admins and ``check_model_access`` rejects them), so
a shared preset cannot be used to reach a base model the caller could
not use directly. Returns ``False`` the moment any hop denies access.
"""
from open_webui.models.access_grants import AccessGrants
from open_webui.models.models import Models
@@ -280,7 +325,7 @@ async def has_base_model_access(
seen.add(base_model_id)
base_model_info = await Models.get_model_by_id(base_model_id, db=db)
if base_model_info is None:
break # Raw provider model — no per-model ACL
return user_role == 'admin'
if not (
user_id == base_model_info.user_id
or await AccessGrants.has_access(
@@ -339,7 +384,7 @@ async def check_model_access(
raise HTTPException(status_code=403, detail='Model not found')
# Enforce access on chained base models
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids):
if not await has_base_model_access(user.id, model_info, user_role=user.role, user_group_ids=user_group_ids):
raise HTTPException(status_code=403, detail='Model not found')
else:
if user.role != 'admin':
@@ -4,20 +4,24 @@ from open_webui.models.access_grants import AccessGrants
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.models.folders import FolderModel
from open_webui.models.groups import Groups
from open_webui.models.knowledge import Knowledges
from open_webui.models.models import Models
from open_webui.models.users import UserModel
from open_webui.models.users import UserModel, Users
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
FOLDER_FILE_TYPES = {'file', 'collection', 'note'}
async def has_access_to_file(
file_id: str | None,
access_type: str,
user: UserModel,
db: AsyncSession | None = None,
user_group_ids: set[str] | None = None,
) -> bool:
"""
Check if a user has the specified access to a file through any of:
@@ -43,7 +47,8 @@ async def has_access_to_file(
# the object's OWNER owns that file; otherwise a read-only file laundered into an object
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
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)}
for knowledge_base in knowledge_bases:
if (
knowledge_base.user_id == user.id
@@ -60,12 +65,25 @@ async def has_access_to_file(
knowledge_base_id = file.meta.get('collection_name') if file.meta else None
if knowledge_base_id:
knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id and (
access_type == 'read' or knowledge_base.user_id == file.user_id
):
return True
# Fetch the one referenced knowledge base instead of listing every
# knowledge base the user can access just to scan for this id.
knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id, db=db)
if (
knowledge_base
and (access_type == 'read' or knowledge_base.user_id == file.user_id)
and (
knowledge_base.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
)
)
):
return True
# Check if the file is associated with any channels the user has access to
channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
@@ -88,7 +106,9 @@ async def has_access_to_file(
# Check if the file is directly attached to a shared workspace model (per the ownership
# note above, model write is conferred only for files the model owner owns).
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
for model in await Models.get_models_by_user_id(
user.id, permission=access_type, db=db, user_group_ids=user_group_ids
):
knowledge_items = getattr(model.meta, 'knowledge', None) or []
for item in knowledge_items:
if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:
@@ -105,29 +125,74 @@ async def get_accessible_folder_files(
) -> list[dict]:
"""Filter folder.data['files'] entries to those the caller can read.
Each entry is expected to have 'type' ('file' or 'collection') and 'id'.
Admins bypass all checks. Unknown types are kept as-is.
Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. Entries of any other
shape are dropped because they cannot be access-checked.
"""
if not entries:
if not isinstance(entries, list):
return []
entries = [
entry
for entry in entries
if isinstance(entry, dict) and entry.get('type') in FOLDER_FILE_TYPES and entry.get('id')
]
if user.role == 'admin':
return list(entries)
return entries
# One group-membership fetch for the whole folder listing
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
accessible: list[dict] = []
for entry in entries:
if not isinstance(entry, dict):
continue
entry_type = entry.get('type')
entry_id = entry.get('id')
if not entry_id:
accessible.append(entry)
continue
if entry_type == 'file':
if await has_access_to_file(entry_id, 'read', user, db=db):
if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids):
accessible.append(entry)
elif entry_type == 'collection':
if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db):
accessible.append(entry)
else:
accessible.append(entry)
elif entry_type == 'note':
# Owner has no self-grant (notes are private by default), so check ownership too.
from open_webui.models.notes import Notes
note = await Notes.get_note_by_id(entry_id, db=db)
if note and (
note.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=entry_id,
permission='read',
db=db,
)
):
accessible.append(entry)
return accessible
async def can_read_all_folder_files(
entries: list[dict] | None,
user: UserModel,
db: AsyncSession | None = None,
) -> bool:
if entries is None:
return True
if not isinstance(entries, list):
return False
if not entries:
return True
return len(await get_accessible_folder_files(entries, user, db=db)) == len(entries)
async def get_owner_accessible_folder_files(folder: FolderModel, db: AsyncSession | None = None) -> list[dict]:
"""Return the folder entries its owner can still delegate."""
files = (folder.data or {}).get('files') or []
if not files:
return []
owner = await Users.get_user_by_id(folder.user_id, db=db)
if not owner:
return []
return await get_accessible_folder_files(files, owner, db=db)
+22 -2
View File
@@ -4,12 +4,12 @@ import sys
from typing import Any
from fastapi import Request
from open_webui.env import GLOBAL_LOG_LEVEL
from open_webui.env import ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
from open_webui.models.functions import Functions
from open_webui.models.users import UserModel
from open_webui.socket.main import get_event_call, get_event_emitter
from open_webui.utils.middleware import process_tool_result
from open_webui.utils.models import get_all_models
from open_webui.utils.models import check_model_access, get_all_models
from open_webui.utils.plugin import get_function_module_from_cache
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
@@ -17,6 +17,9 @@ log = logging.getLogger(__name__)
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
if not ENABLE_PLUGINS:
raise Exception('Plugins are disabled by ENABLE_PLUGINS=false')
if '.' in action_id:
action_id, sub_action_id = action_id.split('.')
else:
@@ -43,6 +46,23 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
raise Exception('Model not found')
model = models[model_id]
# Availability gate — keep this route consistent with the actions a model
# actually surfaces to the client. Executing admin-authored Function code is
# intended; this only stops a disabled, unassigned, or access-restricted
# action from being reached by calling the route with a raw action_id.
if action.type != 'action' or not action.is_active:
raise Exception(f'Action not available: {action_id}')
# Direct connections carry a client-supplied model the caller already owns,
# so scope the model-bound checks to server-resolved models.
if not getattr(request.state, 'direct', False) and user.role != 'admin':
await check_model_access(user, model)
# model['actions'] entries are '<function_id>' or '<function_id>.<sub_id>';
# the function id is always the prefix.
surfaced_action_ids = {item.get('id', '').split('.', 1)[0] for item in model.get('actions', [])}
if action_id not in surfaced_action_ids:
raise Exception(f'Action not available: {action_id}')
__event_emitter__ = await get_event_emitter(
{
'chat_id': data['chat_id'],
+245 -21
View File
@@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
ANTHROPIC_CONVERTED_REQUEST_PARAMS = {
'model',
'messages',
'system',
'max_tokens',
'temperature',
'top_p',
'top_k',
'stop_sequences',
'stream',
'metadata',
'service_tier',
'tools',
'tool_choice',
'reasoning_effort',
}
def is_anthropic_url(url: str) -> bool:
"""Check if the URL is an Anthropic API endpoint."""
@@ -109,7 +126,16 @@ def _finalize_openai_content(blocks: list) -> str | list:
return blocks
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
def is_anthropic_messages_passthrough(url: str, api_config: dict | None = None) -> bool:
api_config = api_config or {}
provider = str(api_config.get('provider', '')).lower()
return is_anthropic_url(url or '') or provider == 'litellm'
def convert_anthropic_to_openai_payload(
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
) -> dict:
"""
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
@@ -173,6 +199,8 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
},
)
)
elif block_type in ('thinking', 'redacted_thinking'):
openai_content.append(_copy_cache_control(block, dict(block)))
elif block_type == 'image':
source = block.get('source', {})
if source.get('type') == 'base64':
@@ -350,6 +378,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
if 'max_tokens' in anthropic_payload:
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
captured_passthrough_params = {
param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS
}
if isinstance(passthrough_params, str):
passthrough_params = passthrough_params.split(',')
elif not isinstance(passthrough_params, (list, tuple, set)):
passthrough_params = []
passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()}
if '*' in passthrough_param_names:
openai_payload.update(captured_passthrough_params)
else:
for param in passthrough_param_names:
if param in captured_passthrough_params:
openai_payload[param] = captured_passthrough_params[param]
output_config = anthropic_payload.get('output_config')
if isinstance(output_config, dict):
if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload:
openai_payload['reasoning_effort'] = output_config['effort']
format_config = output_config.get('format')
if isinstance(format_config, dict):
format_type = format_config.get('type')
if format_type == 'json_schema':
json_schema = {
'name': format_config.get('name', 'response_schema'),
'schema': format_config.get('schema', {}),
}
if 'description' in format_config:
json_schema['description'] = format_config['description']
if 'strict' in format_config:
json_schema['strict'] = format_config['strict']
openai_payload['response_format'] = {
'type': 'json_schema',
'json_schema': json_schema,
}
elif format_type == 'json_object':
openai_payload['response_format'] = {'type': format_type}
if 'reasoning_effort' in anthropic_payload:
openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort']
# Common parameters
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
if param in anthropic_payload:
@@ -395,7 +465,9 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
return openai_payload
def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') -> dict:
def convert_openai_to_anthropic_response(
openai_response: dict, model: str = '', input_tokens: int | None = None
) -> dict:
"""
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
"""
@@ -419,6 +491,37 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
# Build content blocks
content = []
message_thinking = message.get('thinking')
thinking_blocks = message.get('thinking_blocks') or []
if not thinking_blocks and isinstance(message_thinking, dict):
thinking_blocks = message_thinking.get('blocks') or []
has_thinking = False
for block in thinking_blocks:
if not isinstance(block, dict):
continue
if block.get('type') == 'redacted_thinking':
content.append({k: v for k, v in block.items() if k in {'type', 'data'}})
has_thinking = True
continue
thinking = block.get('thinking') or block.get('content') or block.get('text')
if not thinking:
continue
thinking_block = {'type': 'thinking', 'thinking': thinking}
if block.get('signature'):
thinking_block['signature'] = block['signature']
content.append(thinking_block)
has_thinking = True
reasoning_content = message.get('reasoning_content') or message.get('reasoning')
if not reasoning_content and isinstance(message_thinking, str):
reasoning_content = message_thinking
if reasoning_content and not has_thinking:
content.append({'type': 'thinking', 'thinking': reasoning_content})
message_content = message.get('content')
if message_content:
content.append({'type': 'text', 'text': message_content})
@@ -441,15 +544,37 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
)
# Usage
openai_usage = openai_response.get('usage', {})
openai_usage = openai_response.get('usage') or {}
cache_creation = openai_usage.get('cache_creation_input_tokens')
cache_read = openai_usage.get('cache_read_input_tokens')
prompt_details = openai_usage.get('prompt_tokens_details')
if cache_read is None and isinstance(prompt_details, dict):
cache_read = prompt_details.get('cached_tokens')
usage_input = openai_usage.get('input_tokens')
if usage_input is None:
prompt_tokens = openai_usage.get('prompt_tokens')
if prompt_tokens is not None:
usage_input = max(prompt_tokens - (cache_creation or 0) - (cache_read or 0), 0)
usage_output = openai_usage.get('output_tokens')
if usage_output is None:
usage_output = openai_usage.get('completion_tokens')
usage = {
'input_tokens': openai_usage.get('prompt_tokens', 0),
'output_tokens': openai_usage.get('completion_tokens', 0),
'input_tokens': usage_input if usage_input is not None else (input_tokens if input_tokens is not None else 0),
'output_tokens': usage_output if usage_output is not None else 0,
}
if 'cache_creation_input_tokens' in openai_usage:
usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens']
if 'cache_read_input_tokens' in openai_usage:
usage['cache_read_input_tokens'] = openai_usage['cache_read_input_tokens']
if cache_creation is not None:
usage['cache_creation_input_tokens'] = cache_creation
if cache_read is not None:
usage['cache_read_input_tokens'] = cache_read
if isinstance(openai_usage.get('output_tokens_details'), dict):
usage['output_tokens_details'] = openai_usage['output_tokens_details']
if isinstance(openai_usage.get('server_tool_use'), dict):
usage['server_tool_use'] = openai_usage['server_tool_use']
if openai_usage.get('service_tier') is not None:
usage['service_tier'] = openai_usage['service_tier']
return {
'id': openai_response.get('id', f'msg_{_uuid.uuid4().hex[:24]}'),
@@ -463,7 +588,7 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
}
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ''):
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = '', input_tokens: int | None = None):
"""
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
@@ -480,13 +605,18 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
import uuid as _uuid
message_id = f'msg_{_uuid.uuid4().hex[:24]}'
input_tokens = 0
output_tokens = 0
cache_creation_input_tokens = None
cache_read_input_tokens = None
output_tokens_details = None
server_tool_use = None
service_tier = None
stop_reason = 'end_turn'
# Track content blocks with a running index.
# Each text block or tool_use block gets its own index.
current_block_index = 0
thinking_block_open = False
text_block_open = False
# Accumulated state for each tool call, keyed by tool call id.
@@ -510,7 +640,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
'model': model,
'stop_reason': None,
'stop_sequence': None,
'usage': {'input_tokens': 0, 'output_tokens': 0},
'usage': {'input_tokens': input_tokens or 0, 'output_tokens': 0},
},
}
yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode()
@@ -537,28 +667,93 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
except (json.JSONDecodeError, TypeError):
continue
usage_data = data.get('usage')
if isinstance(usage_data, dict):
cache_creation = usage_data.get('cache_creation_input_tokens')
cache_read = usage_data.get('cache_read_input_tokens')
prompt_details = usage_data.get('prompt_tokens_details')
if cache_read is None and isinstance(prompt_details, dict):
cache_read = prompt_details.get('cached_tokens')
usage_input = usage_data.get('input_tokens')
if usage_input is None:
prompt_tokens = usage_data.get('prompt_tokens')
if prompt_tokens is not None:
usage_input = max(prompt_tokens - (cache_creation or 0) - (cache_read or 0), 0)
usage_output = usage_data.get('output_tokens')
if usage_output is None:
usage_output = usage_data.get('completion_tokens')
if usage_input is not None:
input_tokens = usage_input
if usage_output is not None:
output_tokens = usage_output
if cache_creation is not None:
cache_creation_input_tokens = cache_creation
if cache_read is not None:
cache_read_input_tokens = cache_read
if isinstance(usage_data.get('output_tokens_details'), dict):
output_tokens_details = usage_data['output_tokens_details']
if isinstance(usage_data.get('server_tool_use'), dict):
server_tool_use = usage_data['server_tool_use']
if usage_data.get('service_tier') is not None:
service_tier = usage_data['service_tier']
choices = data.get('choices', [])
if not choices:
# Check for usage in the final chunk
if data.get('usage'):
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
output_tokens = data['usage'].get('completion_tokens', output_tokens)
continue
delta = choices[0].get('delta', {})
finish_reason = choices[0].get('finish_reason')
message = choices[0].get('message') or {}
# Update usage if present
if data.get('usage'):
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
output_tokens = data['usage'].get('completion_tokens', output_tokens)
reasoning_content = (
delta.get('reasoning_content')
or delta.get('reasoning')
or delta.get('thinking')
or message.get('reasoning_content')
or message.get('reasoning')
)
if not reasoning_content:
thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or []
for block in thinking_blocks:
if isinstance(block, dict):
reasoning_content = block.get('thinking') or block.get('content') or block.get('text')
if reasoning_content:
break
if reasoning_content and not text_block_open and not has_tool_calls:
if not thinking_block_open:
block_start = {
'type': 'content_block_start',
'index': current_block_index,
'content_block': {'type': 'thinking', 'thinking': ''},
}
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
thinking_block_open = True
block_delta = {
'type': 'content_block_delta',
'index': current_block_index,
'delta': {'type': 'thinking_delta', 'thinking': reasoning_content},
}
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# --- Handle text content ---
# Anthropic expects text blocks before tool blocks, so skip
# text deltas once any tool call has started.
content = delta.get('content')
if content and not has_tool_calls:
if thinking_block_open:
block_stop = {
'type': 'content_block_stop',
'index': current_block_index,
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
thinking_block_open = False
current_block_index += 1
if not text_block_open:
block_start = {
'type': 'content_block_start',
@@ -584,6 +779,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
if tool_calls:
# Close text block if one is open (text comes before tools)
if thinking_block_open:
block_stop = {
'type': 'content_block_stop',
'index': current_block_index,
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
thinking_block_open = False
current_block_index += 1
if text_block_open:
block_stop = {
'type': 'content_block_stop',
@@ -695,6 +899,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
except Exception as e:
log.error(f'Error in Anthropic stream conversion: {e}')
# Close any open thinking block
if thinking_block_open:
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
current_block_index += 1
# Flush any tools that buffered arguments but never emitted a block
for tool in tracked_tool_calls.values():
if not tool['started'] and tool['name']:
@@ -737,13 +947,27 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
# Emit message_delta with stop reason
usage = {'output_tokens': output_tokens}
if input_tokens is not None:
usage['input_tokens'] = input_tokens
if cache_creation_input_tokens is not None:
usage['cache_creation_input_tokens'] = cache_creation_input_tokens
if cache_read_input_tokens is not None:
usage['cache_read_input_tokens'] = cache_read_input_tokens
if output_tokens_details is not None:
usage['output_tokens_details'] = output_tokens_details
if server_tool_use is not None:
usage['server_tool_use'] = server_tool_use
if service_tier is not None:
usage['service_tier'] = service_tier
message_delta = {
'type': 'message_delta',
'delta': {
'stop_reason': stop_reason,
'stop_sequence': None,
},
'usage': {'output_tokens': output_tokens},
'usage': usage,
}
yield f'event: message_delta\ndata: {json.dumps(message_delta)}\n\n'.encode()
+24 -14
View File
@@ -39,7 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.security import HTTPAuthorizationCredentials
from open_webui.env import CUSTOM_API_KEY_HEADER
from open_webui.internal.db import ScopedSession
from open_webui.models.config import Config
from open_webui.utils.auth import get_http_authorization_cred
from starlette.datastructures import MutableHeaders
from starlette.requests import Request
@@ -101,14 +100,20 @@ class CommitSessionMiddleware:
# Downstream did not complete successfully. Roll back any
# pending sync writes, release the connection, and let the
# exception propagate.
try:
ScopedSession.rollback()
except Exception:
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
finally:
ScopedSession.remove()
if ScopedSession.registry.has():
try:
ScopedSession.rollback()
except Exception:
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
finally:
ScopedSession.remove()
raise
# Nothing in this request touched the sync session: committing would
# only instantiate one to run an empty transaction.
if not ScopedSession.registry.has():
return
# Downstream completed. Commit pending sync work.
try:
ScopedSession.commit()
@@ -138,9 +143,7 @@ class AuthTokenMiddleware:
the middleware checks that instead and avoids the 401 short-circuit.
Routes that depend on `get_verified_user` etc. read this state.
Also exposes `request.state.enable_api_keys` (snapshotted at request
entry from runtime config) and stamps an `X-Process-Time` response
header.
Also stamps an `X-Process-Time` response header.
"""
def __init__(self, app: ASGIApp, *, fastapi_app) -> None:
@@ -166,13 +169,12 @@ class AuthTokenMiddleware:
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
request.state.token = token
request.state.enable_api_keys = await Config.get('auth.enable_api_keys')
async def send_with_timing(message: Message) -> None:
if message['type'] == 'http.response.start':
process_time = int(time.monotonic() - start_time)
process_time = time.monotonic() - start_time
headers = MutableHeaders(scope=message)
headers['X-Process-Time'] = str(process_time)
headers['X-Process-Time'] = f'{process_time:.6f}'
await send(message)
await self.app(scope, receive, send_with_timing)
@@ -231,7 +233,15 @@ class RedirectMiddleware:
return
path = scope.get('path', '')
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
raw_query = scope.get('query_string', b'')
# This middleware only acts on /watch?v= and ?shared= URLs; skip the
# decode + parse_qs work for every other GET. (A false positive on the
# substring check just falls through to the full parse below.)
if not (path.endswith('/watch') or b'shared' in raw_query):
await self.app(scope, receive, send)
return
query_string = raw_query.decode('latin-1', errors='replace')
query_params = parse_qs(query_string)
redirect_params: dict[str, str] = {}
+32 -15
View File
@@ -132,14 +132,27 @@ class AuditLoggingMiddleware:
) -> None:
self.app = app
self.audit_logger = AuditLogger(logger)
self.excluded_paths = excluded_paths or []
self.included_paths = included_paths or []
def normalize_paths(paths: Optional[list[str]]) -> list[str]:
return [path for path in (path.strip().lstrip('/') for path in paths or []) if path]
self.excluded_paths = normalize_paths(excluded_paths)
self.included_paths = normalize_paths(included_paths)
self.max_body_size = max_body_size
self.audited_methods = set(self.DEFAULT_AUDITED_METHODS)
if audit_get_requests:
self.audited_methods.add('GET')
self.audit_level = audit_level
# Paths are fixed for the process lifetime; compile once instead of
# per request. None means the corresponding mode has nothing to match.
self._included_pattern = (
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') if self.included_paths else None
)
self._excluded_pattern = (
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') if self.excluded_paths else None
)
if self.included_paths and self.excluded_paths:
logger.warning(
'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. '
@@ -196,6 +209,13 @@ class AuditLoggingMiddleware:
await self._log_audit_entry(request, context)
async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
# get_current_user stashes the resolved user on the scope-backed state;
# reuse it instead of running the full auth pipeline (JWT decode, Redis
# revocation checks, DB fetch, last-active write) a second time.
user = getattr(request.state, 'user', None)
if isinstance(user, UserModel):
return user
auth_header = request.headers.get('Authorization')
try:
@@ -206,6 +226,12 @@ class AuditLoggingMiddleware:
return None
ALWAYS_LOG_ENDPOINTS = (
'/api/v1/auths/signin',
'/api/v1/auths/signout',
'/api/v1/auths/signup',
)
def _should_skip_auditing(self, request: Request) -> bool:
if AUDIT_LOG_LEVEL == 'NONE':
return True
@@ -213,13 +239,8 @@ class AuditLoggingMiddleware:
if request.method not in self.audited_methods:
return True
ALWAYS_LOG_ENDPOINTS = {
'/api/v1/auths/signin',
'/api/v1/auths/signout',
'/api/v1/auths/signup',
}
path = request.url.path.lower()
for endpoint in ALWAYS_LOG_ENDPOINTS:
for endpoint in self.ALWAYS_LOG_ENDPOINTS:
if path.startswith(endpoint):
return False # Do NOT skip logging for auth endpoints
@@ -229,15 +250,11 @@ class AuditLoggingMiddleware:
return True
# Whitelist mode: only log paths that match included_paths
if self.included_paths:
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b')
if not pattern.match(request.url.path):
return True # Skip: path not in whitelist
return False # Do NOT skip: path is in whitelist
if self._included_pattern:
return not self._included_pattern.match(request.url.path)
# Blacklist mode: skip paths that match excluded_paths
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b')
if pattern.match(request.url.path):
if self._excluded_pattern and self._excluded_pattern.match(request.url.path):
return True
return False
+47 -18
View File
@@ -96,11 +96,15 @@ def get_license_data(app, key):
setattr(app.state, 'LICENSE_METADATA', v)
def handler(u):
res = requests.post(
f'{u}/api/v1/license/',
json={'key': key, 'version': '1'},
timeout=5,
)
try:
res = requests.post(
f'{u}/api/v1/license/',
json={'key': key, 'version': '1'},
timeout=5,
)
except Exception as ex:
log.error(f'License: retrieval issue from {u}: {ex}')
return False
if getattr(res, 'ok', False):
payload = getattr(res, 'json', lambda: {})()
@@ -352,6 +356,8 @@ async def get_current_user(
current_span.set_attribute('client.user.role', user.role)
current_span.set_attribute('client.auth.type', 'api_key')
# Scope-backed, so outer middleware (audit) can reuse the resolved user
request.state.user = user
return user
# auth by jwt token
@@ -399,9 +405,10 @@ async def get_current_user(
# Refresh the user's last active timestamp
# Fire-and-forget via asyncio.create_task to avoid blocking
import asyncio
asyncio.create_task(Users.update_last_active_by_id(user.id))
# Scope-backed, so outer middleware (audit) can reuse the resolved user
request.state.user = user
return user
else:
raise HTTPException(
@@ -433,24 +440,30 @@ async def get_current_user_by_api_key(request, api_key: str):
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
user_permissions = await Config.get('user.permissions')
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
config_values = await Config.get_many(
'auth.enable_api_keys',
'user.permissions',
'auth.api_key.endpoint_restrictions',
'auth.api_key.allowed_endpoints',
)
if not request.state.enable_api_keys or (
user.role != 'admin'
and not await has_permission(
if not config_values.get('auth.enable_api_keys'):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
if user.role != 'admin':
user_permissions = config_values.get('user.permissions')
if not await has_permission(
user.id,
'features.api_keys',
user_permissions,
)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
# Enforce endpoint restrictions — checked here (not in middleware)
# so it applies regardless of how the API key was transported
# (Authorization header, cookie, x-api-key header, etc.).
if enable_endpoint_restrictions:
if config_values.get('auth.api_key.endpoint_restrictions'):
allowed_endpoints = config_values.get('auth.api_key.allowed_endpoints', '')
allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()]
request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths)
@@ -475,8 +488,11 @@ async def get_current_user_by_api_key(request, api_key: str):
return user
VERIFIED_USER_ROLES = {'user', 'admin'}
def get_verified_user(user=Depends(get_current_user)):
if user.role not in {'user', 'admin'}:
if user.role not in VERIFIED_USER_ROLES:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -484,6 +500,19 @@ def get_verified_user(user=Depends(get_current_user)):
return user
async def get_verified_user_by_token(token: str, redis=None):
"""Resolve a verified user from a raw token, for WebSocket handshakes that run outside the HTTP dependency chain."""
decoded = decode_token(token)
if decoded is None or 'id' not in decoded or not await is_valid_token(decoded, redis):
return None
user = await Users.get_user_by_id(decoded['id'])
if user is None or user.role not in VERIFIED_USER_ROLES:
return None
return user
def get_admin_user(user=Depends(get_current_user)):
if user.role != 'admin':
raise HTTPException(
+98 -57
View File
@@ -7,9 +7,11 @@ Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
The scheduler_worker_loop handles all time-based background work:
- Automation execution (claim_due → execute)
- Calendar event alerts (upcoming events → socket + webhook notifications)
- One-shot chat timers
Environment:
SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10)
TIMER_POLL_INTERVAL – seconds between timer polls (default: 1)
CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5)
"""
@@ -23,6 +25,7 @@ from typing import Optional
from uuid import uuid4
from zoneinfo import ZoneInfo
from dateutil import parser as date_parser
from dateutil.rrule import rrulestr
from fastapi import Request
from fastapi.security import HTTPAuthorizationCredentials
@@ -32,15 +35,18 @@ from open_webui.internal.db import get_async_db
from open_webui.models.automations import AutomationModel, AutomationRuns, Automations
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.users import Users
from open_webui.utils.auth import create_token
from open_webui.utils.misc import parse_duration
from open_webui.utils.task import prompt_template
from open_webui.utils.terminals import get_terminal_server_url
from starlette.datastructures import Headers
log = logging.getLogger(__name__)
SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10')))
TIMER_POLL_INTERVAL = int(os.getenv('TIMER_POLL_INTERVAL', '1'))
CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10'))
@@ -65,19 +71,49 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
return None
def _parse_rule(s: str):
def _parse_rule(s: str, now: Optional[datetime] = None):
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
SECONDLY/MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10).
"""
raw = s.replace('RRULE:', '')
parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p)
lines = s.splitlines()
rule_count = sum(1 for line in lines if line.upper().startswith('RRULE:'))
if 'EXRULE' in s.upper():
raise ValueError('EXRULE is not supported in recurrence rules')
if rule_count > 1:
raise ValueError('only one RRULE is supported per recurrence rule')
rrule_line = next((line for line in lines if line.upper().startswith('RRULE:')), s)
raw = rrule_line.split(':', 1)[1] if rrule_line.upper().startswith('RRULE:') else rrule_line
parts = {k.upper(): v for k, v in (p.split('=', 1) for p in raw.split(';') if '=' in p)}
freq = parts.get('FREQ', '')
if freq in ('MINUTELY', 'HOURLY'):
if freq in ('SECONDLY', 'MINUTELY', 'HOURLY'):
epoch = datetime(2000, 1, 1, 0, 0, 0)
return rrulestr(s, dtstart=epoch, ignoretz=True)
anchor = now or datetime.now()
rule = '\n'.join(line for line in lines if not line.upper().startswith('DTSTART')) or s
dtstart = next((line.rsplit(':', 1)[-1] for line in lines if line.upper().startswith('DTSTART')), None)
interval = int(parts.get('INTERVAL', '1'))
if interval < 1:
raise ValueError('RRULE INTERVAL must be a positive integer')
if freq == 'SECONDLY':
step = timedelta(seconds=interval)
elif freq == 'MINUTELY':
step = timedelta(minutes=interval)
else:
step = timedelta(hours=interval)
if dtstart:
start = date_parser.parse(dtstart, ignoretz=True)
emitted = ((anchor - start) // step) if anchor > start else 0
if 'BYMINUTE' in parts:
emitted *= len(parts['BYMINUTE'].split(','))
if 'BYSECOND' in parts:
emitted *= len(parts['BYSECOND'].split(','))
if emitted <= 100_000:
return rrulestr(s, ignoretz=True)
anchor = epoch + ((anchor - epoch) // step) * step
return rrulestr(rule, dtstart=anchor, ignoretz=True)
return rrulestr(s, ignoretz=True)
@@ -88,12 +124,12 @@ def validate_rrule(s: str, tz: str = None) -> None:
clock so that near-future schedules are not incorrectly rejected
on servers whose system clock is ahead (e.g. UTC vs US timezones).
"""
try:
rule = _parse_rule(s)
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
zi = _resolve_tz(tz)
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
try:
rule = _parse_rule(s, now)
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
if rule.after(now) is None:
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
@@ -102,7 +138,8 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]:
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
zi = _resolve_tz(tz)
now = datetime.now(zi) if zi else datetime.now()
dt = _parse_rule(s).after(now.replace(tzinfo=None))
now_naive = now.replace(tzinfo=None)
dt = _parse_rule(s, now_naive).after(now_naive)
if dt is None:
return None
if zi:
@@ -117,9 +154,9 @@ def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
preview matches the user's local clock (same as next_run_ns).
"""
zi = _resolve_tz(tz)
rule = _parse_rule(s)
result = []
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
rule = _parse_rule(s, now)
dt = now
for _ in range(n):
dt = rule.after(dt)
@@ -141,8 +178,8 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
"""
if 'COUNT=1' in s:
return None
rule = _parse_rule(s)
now = datetime.now()
rule = _parse_rule(s, now)
first = rule.after(now)
if first is None:
return None
@@ -173,9 +210,30 @@ async def scheduler_worker_loop(app) -> None:
Runs on every instance. Poll interval is configurable via
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
"""
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
log.info(
f'Scheduler worker started (timer poll interval: {TIMER_POLL_INTERVAL}s, '
f'scheduler poll interval: {SCHEDULER_POLL_INTERVAL}s)'
)
next_scheduler_poll = 0.0
while True:
try:
now = time.monotonic()
# ── Timers ──
try:
from open_webui.utils.timers import claim_due_timers, execute_due_timer
for timer_id, claim_id in await claim_due_timers(int(time.time_ns()), limit=10):
asyncio.create_task(execute_due_timer(app, timer_id, claim_id))
except Exception:
log.exception('Scheduler: timer error')
if now < next_scheduler_poll:
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
continue
# Jitter to spread automation/calendar load across instances; timers keep a tight poll.
next_scheduler_poll = now + SCHEDULER_POLL_INTERVAL + random.uniform(0, 2)
# ── Automations ──
if await Config.get('automations.enable'):
try:
@@ -198,8 +256,7 @@ async def scheduler_worker_loop(app) -> None:
except Exception:
log.exception('Scheduler worker error')
# Jitter to spread load across instances
await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2))
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
##########################
@@ -323,16 +380,11 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
log.warning(f'Terminal server {server_id} not found for CWD set')
return
base_url = (connection.get('url') or '').rstrip('/')
base_url = get_terminal_server_url(connection)
if not base_url:
return
# Build target URL — route through orchestrator policy if configured
policy_id = connection.get('policy_id')
if connection.get('server_type') == 'orchestrator' and policy_id:
target_url = f'{base_url}/p/{policy_id}/files/cwd'
else:
target_url = f'{base_url}/files/cwd'
target_url = f'{base_url}/files/cwd'
headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
if chat_id:
@@ -396,7 +448,10 @@ async def execute_automation(app, automation: AutomationModel) -> None:
prompt = await prompt_template(automation.data['prompt'], user)
model_id = automation.data['model_id']
terminal_config = automation.data.get('terminal')
folder_id = automation.folder_id
if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id):
await Automations.clear_folder_ids(automation.user_id, [folder_id])
folder_id = None
# Generate proper UUIDs for messages (same as frontend)
user_msg_id = str(uuid4())
@@ -407,6 +462,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
chat_id,
automation.user_id,
ChatForm(
folder_id=folder_id,
chat={
'title': automation.name,
'models': [model_id],
@@ -438,7 +494,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
{'role': 'user', 'content': prompt},
],
'meta': {'automation_id': automation.id},
}
},
),
)
@@ -613,40 +669,25 @@ async def _check_calendar_alerts(app) -> None:
except Exception:
log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True)
# Send webhook notification if user has one configured
# Send target notification if user has one configured
try:
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
if enable_user_webhooks:
user = await Users.get_user_by_id(event.user_id)
if user and user.settings:
webhook_url = (
user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
if isinstance(user.settings, dict)
else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)(
'notifications', {}
).get('webhook_url', None)
if hasattr(user.settings, 'ui')
else None
)
if webhook_url:
from open_webui.utils.webhook import post_webhook
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
await post_webhook(
webui_name,
webhook_url,
f'{event.title} — starting {time_str}',
{
'action': 'calendar_alert',
'title': event.title,
'minutes_until': minutes_until,
'event_id': event.id,
},
)
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
await publish_event(
app,
EVENTS.CALENDAR_ALERT,
subject_id=event.id,
subject_type='calendar.event',
source='scheduler',
data={
**alert_data,
'user_id': event.user_id,
'starts_in': time_str,
'message': f'{event.title}: starting {time_str}',
},
message=event.title,
)
except Exception:
log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True)
log.debug(f'Failed to send notification for calendar alert {event.id}', exc_info=True)
async def _record_run(
+10 -8
View File
@@ -11,7 +11,6 @@ from aiocache import cached
from fastapi import HTTPException, Request, status
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
from open_webui.functions import generate_function_chat_completion
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel
from open_webui.routers.ollama import (
@@ -30,7 +29,7 @@ from open_webui.socket.main import (
sio,
)
from open_webui.utils.filter import (
get_sorted_filter_ids,
get_filter_functions,
process_filter_functions,
)
from open_webui.utils.models import check_model_access, get_all_models
@@ -179,8 +178,10 @@ async def generate_chat_completion(
# Merge the direct connection model into server models so that
# task functions (title, tags, etc.) can resolve a server-side
# task model while still having the direct model available.
# dict(...items()) is one HGETALL on a Redis-backed pool; ``{**pool}``
# would issue HKEYS plus one HGET per model.
models = {
**request.app.state.MODELS,
**dict(request.app.state.MODELS.items()),
request.state.model['id']: request.state.model,
}
log.debug(f'direct connection to model: {request.state.model["id"]}')
@@ -188,11 +189,12 @@ async def generate_chat_completion(
models = request.app.state.MODELS
model_id = form_data['model']
if model_id not in models:
# Single lookup — membership check plus getitem would be two Redis
# round trips on a Redis-backed model pool.
model = models.get(model_id)
if model is None:
raise Exception('Model not found')
model = models[model_id]
if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'):
return await generate_direct_chat_completion(request, form_data, user=user, models=models)
else:
@@ -359,11 +361,11 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
}
try:
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
filter_functions = await Functions.get_functions_by_ids(filter_ids)
filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', []))
result, _ = await process_filter_functions(
request=request,
filter_context=None,
filter_functions=filter_functions,
filter_type='outlet',
form_data=data,
+41
View File
@@ -0,0 +1,41 @@
from copy import deepcopy
def build_fork_history(messages_map: dict, source_message_id: str) -> tuple[dict, list[dict]]:
if not messages_map:
raise ValueError('chat has no messages to fork')
branch: list[tuple[str, dict]] = []
seen: set[str] = set()
message_id = source_message_id
while message_id:
if message_id in seen:
raise ValueError('message branch contains a cycle')
seen.add(message_id)
message = messages_map.get(message_id)
if not isinstance(message, dict):
raise ValueError('message not found')
branch.append((message_id, message))
message_id = message.get('parentId')
fork_messages: dict[str, dict] = {}
ordered_messages: list[dict] = []
parent_id = None
for message_id, message in reversed(branch):
copied = deepcopy(message)
copied['id'] = message_id
copied['parentId'] = parent_id
copied['childrenIds'] = []
if parent_id:
fork_messages[parent_id]['childrenIds'] = [message_id]
fork_messages[message_id] = copied
ordered_messages.append(copied)
parent_id = message_id
return {'messages': fork_messages, 'currentId': source_message_id}, ordered_messages
+27
View File
@@ -0,0 +1,27 @@
from typing import Optional
TEMPORARY_CHAT_ID_PREFIX = 'temporary:'
LEGACY_TEMPORARY_CHAT_ID_PREFIX = 'local:' # Legacy temporary chat prefix.
CHANNEL_CHAT_ID_PREFIX = 'channel:'
TEMPORARY_CHAT_ID_PREFIXES = (
TEMPORARY_CHAT_ID_PREFIX,
LEGACY_TEMPORARY_CHAT_ID_PREFIX,
)
NON_SAVED_CHAT_ID_PREFIXES = (*TEMPORARY_CHAT_ID_PREFIXES, CHANNEL_CHAT_ID_PREFIX)
def is_saved_chat_id(chat_id: Optional[str]) -> bool:
return bool(chat_id) and not chat_id.startswith(NON_SAVED_CHAT_ID_PREFIXES)
def is_temporary_chat_id(chat_id: Optional[str]) -> bool:
return bool(chat_id) and chat_id.startswith(TEMPORARY_CHAT_ID_PREFIXES)
def get_temporary_chat_session_id(chat_id: str) -> Optional[str]:
for prefix in TEMPORARY_CHAT_ID_PREFIXES:
if chat_id.startswith(prefix):
return chat_id.removeprefix(prefix)
return None

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