Compare commits

...
839 Commits
Author SHA1 Message Date
ecd48e2f71 0.10.2 (#26642)
* i18n: add pt-BR translations for newly added UI items and consistency pass (#26391)

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

* refac

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

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

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

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

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

Fixes #26451

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

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

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

* refac

* refac

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

* refac

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

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

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

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Update CHANGELOG.md (#26641)

* Update CHANGELOG.md

* Update CHANGELOG.md

---------

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

---------

Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Sicknine <156204309+SNaytiP@users.noreply.github.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: JuanMa Diaz <torgus@gmail.com>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>
2026-07-01 03:40:54 -05:00
Classic298andTim Baek 4b55e69640 Update CHANGELOG.md (#26641)
* Update CHANGELOG.md

* Update CHANGELOG.md

---------

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

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

Fixes #26451
2026-07-01 02:08:56 -05:00
Timothy Jaeryang Baek 9866a02863 refac 2026-07-01 02:08:34 -05:00
Timothy Jaeryang Baek 2ed8934f5b refac 2026-07-01 02:05:14 -05:00
Timothy Jaeryang Baek 56ee875e21 refac 2026-07-01 01:46:36 -05:00
Timothy Jaeryang Baek 0b0eec05f8 refac 2026-07-01 00:43:00 -05:00
Timothy Jaeryang Baek 58ef80d8f7 refac 2026-07-01 00:10:23 -05:00
Timothy Jaeryang Baek af1c0eee89 refac 2026-06-30 20:20:15 -05:00
Timothy Jaeryang Baek 0b75445ff9 refac 2026-06-30 20:20:10 -05:00
Timothy Jaeryang Baek 139206f0fe refac 2026-06-30 16:59:02 -05:00
Timothy Jaeryang Baek 6ed56b07c4 refac 2026-06-30 16:55:25 -05:00
Sicknine 1014c212a4 i18n(th-TH): translate missing Thai keys/fix typo (#26406) 2026-06-30 15:18:18 -05:00
Timothy Jaeryang Baek 4067e357b2 refac 2026-06-29 21:31:49 -05:00
joaoback 651a02ed2f i18n: add pt-BR translations for newly added UI items and consistency pass (#26391)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-06-29 21:14:01 -05:00
Tim Baek b711935dd5 Merge pull request #26388 from open-webui/dev
0.10.1
2026-06-29 14:38:32 -05:00
Timothy Jaeryang Baek 86231e4438 refac 2026-06-29 14:35:39 -05:00
Timothy Jaeryang Baek f6baa1bb77 refac 2026-06-29 14:34:22 -05:00
Tim Baek 4d2e13cf2b Merge pull request #26385 from open-webui/dev 2026-06-29 14:17:36 -05:00
Timothy Jaeryang Baek 5134e5ecfc refac 2026-06-29 14:03:11 -05:00
Timothy Jaeryang BaekandJannik S. caadfdec0b refac
Co-Authored-By: Jannik S. <jannik@streidl.dev>
2026-06-29 13:47:39 -05:00
Timothy Jaeryang Baek 9af700cc4f chore: bump 2026-06-29 13:45:43 -05:00
Timothy Jaeryang Baek 0c7908b9f2 chore: format 2026-06-29 13:45:00 -05:00
Timothy Jaeryang Baek 06c169f73f chore: format 2026-06-29 13:42:24 -05:00
Timothy Jaeryang Baek 29d0113b37 refac 2026-06-29 13:41:57 -05:00
Classic298 6e3020b942 Update translation.json (#26386) 2026-06-29 13:40:25 -05:00
Timothy Jaeryang Baek 832fc3af84 doc: changelog 2026-06-29 13:39:46 -05:00
Timothy Jaeryang Baek bc9e77384e doc 2026-06-29 13:39:32 -05:00
Timothy Jaeryang Baek 3dc526475d refac 2026-06-29 13:39:29 -05:00
Timothy Jaeryang Baek 89709f5f80 refac 2026-06-29 13:39:08 -05:00
Classic298 09a1c3a948 chore: CHANGELOG (#26016)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2026-06-29 13:37:36 -05:00
Timothy Jaeryang Baek 403392b41b chore: format 2026-06-29 13:35:39 -05:00
Timothy Jaeryang Baek c33fadc266 refac 2026-06-29 13:30:32 -05:00
Timothy Jaeryang Baek 0443ab3a61 refac 2026-06-29 13:30:28 -05:00
Timothy Jaeryang Baek 22a44e67a8 refac 2026-06-29 13:15:29 -05:00
Timothy Jaeryang Baek 24b8619f64 refac 2026-06-29 13:15:11 -05:00
Timothy Jaeryang Baek 3319b6410e refac 2026-06-29 13:14:42 -05:00
Timothy Jaeryang Baek 37d45fdee3 refac 2026-06-29 13:14:13 -05:00
Timothy Jaeryang Baek 55cb98ff56 refac 2026-06-29 13:03:30 -05:00
Timothy Jaeryang Baek 517cd8d102 refac 2026-06-29 13:03:14 -05:00
Timothy Jaeryang Baek 7ea7680f56 refac 2026-06-29 12:59:05 -05:00
Timothy Jaeryang Baek 2c804b0ac4 refac 2026-06-29 12:55:10 -05:00
Timothy Jaeryang Baek 589b62b529 refac 2026-06-29 12:52:51 -05:00
Timothy Jaeryang Baek 21ac7e95a3 refac 2026-06-29 12:51:20 -05:00
Timothy Jaeryang Baek fb27716186 refac 2026-06-29 12:46:37 -05:00
Timothy Jaeryang Baek 37a9da50df refac 2026-06-29 12:46:32 -05:00
Timothy Jaeryang Baek db9977926c refac 2026-06-29 12:43:32 -05:00
Timothy Jaeryang Baek c0c6c2181a refac 2026-06-29 12:40:20 -05:00
Timothy Jaeryang Baek ae5d23f226 refac 2026-06-29 12:38:04 -05:00
G30 c584a4270c perf(ui): non-blocking model and tool server loading for unreachable connections (#26289)
- Move setModels() and setToolServers() out of the loaded gate in
  +layout.svelte so the page renders immediately instead of blocking
  behind slow/unreachable connection timeouts
- Track failed Ollama backend URLs from /api/tags and skip them in the
  subsequent /api/ps fan-out, eliminating a redundant second timeout
- Silence the autocomplete toast that fires during the transient empty
  model state
2026-06-29 12:34:00 -05:00
Shirasawa 91aea7fe8c fix: Fixed the error message display for non-standard SSE (#23228) 2026-06-29 12:31:41 -05:00
Timothy Jaeryang Baek b4073f6378 refac 2026-06-29 12:29:10 -05:00
Timothy Jaeryang Baek bb6b2db88b refac 2026-06-29 12:26:23 -05:00
Timothy Jaeryang Baek 248315de14 refac 2026-06-29 12:25:29 -05:00
Timothy Jaeryang Baek 75db531c12 refac 2026-06-29 12:16:58 -05:00
Timothy Jaeryang Baek c89fd237b8 refac 2026-06-29 12:16:05 -05:00
G30 6fd8c599c1 fix(ui): workspace models bulk actions and search pagination (#26274) 2026-06-29 12:14:15 -05:00
Gateway 877221c118 chore(deps): patch frontend security dependencies (#26281) 2026-06-29 12:13:57 -05:00
Timothy Jaeryang Baek 2856def6c0 refac 2026-06-29 12:12:23 -05:00
Timothy Jaeryang Baek d6cda4a04b refac 2026-06-29 12:12:12 -05:00
Timothy Jaeryang Baek fe3300bd65 refac 2026-06-29 12:10:39 -05:00
Timothy Jaeryang Baek 783205a965 refac 2026-06-29 12:09:16 -05:00
G30 dc1bc41d2e fix(ui): fix pinned note sidebar drag-and-drop into chat context (#26384) 2026-06-29 12:05:39 -05:00
Timothy Jaeryang Baek 655afbe90b refac 2026-06-29 11:59:50 -05:00
Timothy Jaeryang Baek 6f8221df58 refac 2026-06-29 11:59:29 -05:00
Timothy Jaeryang Baek ff5cec43bd refac 2026-06-29 11:56:00 -05:00
Timothy Jaeryang Baek 10558173fb refac 2026-06-29 11:53:29 -05:00
Timothy Jaeryang Baek 754787f43d refac 2026-06-29 11:51:45 -05:00
Classic298 27c97bfe96 i18n: complete German (de-DE) translations (#26383)
Translate all remaining untranslated strings in the German locale: 228 empty
values plus the genuinely translatable members of the value-equals-key set (240
entries total). Covers webhooks/events, external knowledge sources, context
compaction, terminal servers, OAuth/OIDC claims, skills, and directory
management.

Translations follow the existing locale conventions: formal "Sie" address,
established German terms (Websuche, Wissen, Werkzeuge, Wissensspeicher), and
brand/proper nouns, format names, and identical-in-German words left untouched.
2026-06-29 11:47:56 -05:00
Timothy Jaeryang Baek c6ec1a3484 refac 2026-06-29 11:46:08 -05:00
Timothy Jaeryang Baek 40b655e99e refac 2026-06-29 11:44:21 -05:00
Timothy Jaeryang Baek b696c5deff refac 2026-06-29 11:38:59 -05:00
Algorithm5838 7572283517 fix: persist control revert to loaded value (#25793) 2026-06-29 11:37:30 -05:00
Timothy Jaeryang Baek 61cee42ded refac 2026-06-29 11:35:08 -05:00
Timothy Jaeryang Baek 815446d5bb refac 2026-06-29 11:33:49 -05:00
Timothy Jaeryang Baek a146e17bdc refac 2026-06-29 11:33:32 -05:00
Timothy Jaeryang Baek 2c4e1fce8f refac 2026-06-29 11:13:36 -05:00
Timothy Jaeryang Baek 81e245548d refac 2026-06-29 11:00:53 -05:00
Timothy Jaeryang Baek 4ed45ce843 refac 2026-06-29 10:58:57 -05:00
Timothy Jaeryang Baek 2d3035a112 refac 2026-06-29 10:54:53 -05:00
Timothy Jaeryang Baek 39837e0a3a refac 2026-06-29 10:51:52 -05:00
G30 8c7428122b fix(rag): allow special tokens in document text during chunk measurement (#26210)
Documents (especially AI/LLM documentation) legitimately contain special token strings like <|endoftext|> as literal text. The tiktoken encoder raises a ValueError when encountering these during chunk size measurement in merge_docs_to_target_size(), preventing the entire file from being indexed. Pass disallowed_special=() to encoding.encode() to treat all text as normal content.
2026-06-29 10:43:24 -05:00
Juan Calderon-PerezandClaude 51246bcb31 perf(backend): offload blocking calls in async paths to threads (#26381)
Audit of asyncio.sleep vs time.sleep and event-loop-blocking calls:

- utils/plugin.py: run pip `install_frontmatter_requirements`
  (subprocess.check_call) via asyncio.to_thread in load_tool_module_by_id,
  load_function_module_by_id, and install_tool_and_function_dependencies.
- retrieval/utils.py: move the synchronous SSRF-guarded requests probe and
  loader.load() in get_content_from_url into a sync helper run via
  asyncio.to_thread.
- routers/audio.py: write uploaded audio to disk off the event loop in
  transcription().
- routers/pipelines.py: write uploaded pipeline file off the event loop in
  upload_pipeline().

The existing time.sleep call sites are all in genuinely synchronous
functions (sync requests/DB drivers/daemon threads) with async
counterparts that already use asyncio.sleep, so no time.sleep -> asyncio.sleep
changes were needed.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-29 10:42:26 -05:00
Timothy Jaeryang Baek c7e634776d refac 2026-06-29 10:40:42 -05:00
Timothy Jaeryang Baek a5c9459401 refac 2026-06-29 10:40:36 -05:00
Timothy Jaeryang Baek 5055fb85aa refac 2026-06-29 10:40:22 -05:00
Timothy Jaeryang Baek 7ed7e81e84 refac 2026-06-29 10:39:54 -05:00
Timothy Jaeryang Baek 303c426c3f refac 2026-06-29 10:39:47 -05:00
Timothy Jaeryang Baek f9c3ccd869 refac 2026-06-29 10:04:29 -05:00
Timothy Jaeryang BaekandClassic298 eb53281c9a refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-06-29 06:08:31 -05:00
Timothy Jaeryang Baek a285a390c1 refac 2026-06-29 06:01:05 -05:00
Classic298 75df948f34 feat: forward client User-Agent to model backends via {{USER_AGENT}} placeholder (#26333)
Adds a {{USER_AGENT}} custom-header placeholder that relays the inbound
client's User-Agent to upstream model backends, so providers see the real
client instead of Open WebUI's internal aiohttp UA. This makes upstream
usage/cost attribution and backend telemetry possible, and is opt-in
per-connection (no global flag): admins add {{USER_AGENT}} to a connection's
custom headers in Admin > Settings > Connections.

The placeholder is sourced from the live inbound request (with a metadata
fallback for detached RAG/tool calls), so it resolves on every prompt-sending
path, not just chat completions:

- OpenAI completions, Responses API, and proxy — all route through
  get_headers_and_cookies, which now passes the request into get_custom_headers.
- Anthropic Messages API (/api/v1/messages) — already covered, it delegates
  to the chat completion handler.
- Ollama (/api/chat, /v1/completions, /v1/chat/completions, /v1/messages,
  /v1/responses) — previously had no custom-header support at all; send_request
  now applies per-connection custom headers (with templating) for every
  Ollama prompt endpoint.

Custom headers are applied after the built-in user-info headers so explicit
admin-configured headers take precedence. The other existing placeholders
({{CHAT_ID}}, {{USER_ID}}, ...) now also work on the newly covered paths.

Frontend: the connection editor's Headers field is now shown for Ollama
connections too (previously gated to non-Ollama), so the placeholder can be
configured there.

Ref: open-webui/open-webui#26159
2026-06-29 05:58:19 -05:00
Timothy Jaeryang Baek 260f3c3a22 refac 2026-06-29 05:56:08 -05:00
Timothy Jaeryang Baek b0487dd6dd refac 2026-06-29 05:49:54 -05:00
Timothy Jaeryang Baek 70e4ffcc65 refac 2026-06-29 05:49:49 -05:00
Timothy Jaeryang Baek 0883638027 refac 2026-06-29 05:47:21 -05:00
Timothy Jaeryang Baek ee5de69e37 refac 2026-06-29 05:46:55 -05:00
Timothy Jaeryang Baek 0cc331d1c6 refac 2026-06-29 05:46:51 -05:00
Timothy Jaeryang Baek 41f256321b refac 2026-06-29 05:46:47 -05:00
Timothy Jaeryang Baek 44b9463498 refac 2026-06-29 05:44:47 -05:00
Timothy Jaeryang Baek e6d35fc4cc refac 2026-06-29 05:39:17 -05:00
Timothy Jaeryang Baek 396d9ac181 refac 2026-06-29 05:27:51 -05:00
Timothy Jaeryang Baek 67a7b23b85 refac 2026-06-29 05:26:34 -05:00
Timothy Jaeryang Baek edf2c6c8f7 refac 2026-06-29 05:23:20 -05:00
Timothy Jaeryang Baek 0eba3df119 refac 2026-06-29 05:21:49 -05:00
Timothy Jaeryang Baek aa851d93c6 refac 2026-06-29 05:18:12 -05:00
Timothy Jaeryang Baek fa76764c3b refac 2026-06-29 05:16:57 -05:00
Timothy Jaeryang Baek 83ec36cd38 refac 2026-06-29 05:15:55 -05:00
Timothy Jaeryang Baek cdd7b88bec refac 2026-06-29 05:14:57 -05:00
Timothy Jaeryang Baek 8927c9bb3d refac 2026-06-29 05:14:34 -05:00
Timothy Jaeryang Baek 422a4768ea refac 2026-06-29 05:14:03 -05:00
maco c65b29ec0f fix: handle MCP resource content type blob and URI variants (#25260) 2026-06-29 05:12:11 -05:00
Timothy Jaeryang Baek 0be069c165 refac 2026-06-29 05:04:00 -05:00
G30 5ffd4e53c3 fix(ui): prevent long default group name from overflowing admin settings layout (#25685) 2026-06-29 04:59:01 -05:00
G30 4fa3a74827 fix(chat): prevent sortable sidebar drags from triggering file upload overlay (#25675)
Add a custom MIME type (application/x-open-webui-drag) to intentional
chat and folder drag sources. The onDragOver handler in MessageInput
now checks for this type instead of the generic text/plain, which
SortableJS also sets during reorder operations for pinned menu items
(Notes, Workspace) and pinned Models.
2026-06-29 04:58:30 -05:00
Timothy Jaeryang Baek 416baef813 refac 2026-06-29 04:57:28 -05:00
Timothy Jaeryang BaekandPedro Machado 45fea34bd0 refac
Co-Authored-By: Pedro Machado <45520277+pedrofcm@users.noreply.github.com>
2026-06-29 04:46:56 -05:00
Timothy Jaeryang Baek 953432b5fe refac 2026-06-29 04:43:40 -05:00
Timothy Jaeryang Baek e5b5e5917b refac 2026-06-29 04:43:08 -05:00
Timothy Jaeryang Baek 67c9de8efd refac 2026-06-29 04:42:59 -05:00
Timothy Jaeryang Baek a3b487422d refac 2026-06-29 04:42:02 -05:00
G30andTim Baek f53ec857c0 feat: support folder, note, and model drag-and-drop from sidebar as context references (#25771)
* feat(ui): add drag-to-reorder for pinned notes in sidebar

* feat(chat): support folder, note, and model drag-and-drop from sidebar as context references

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-06-29 04:41:16 -05:00
Timothy Jaeryang Baek b617d56c60 refac 2026-06-29 04:37:38 -05:00
Alberto de la Cruz 718b226177 fix: discover MCP Protected Resource Metadata when server does not return 401 (#25980)
get_protected_resource_metadata() only attempted RFC 9728 discovery when
the anonymous `initialize` probe returned 401 with a WWW-Authenticate
header. Some remote MCP servers — notably Google's Gmail/Drive/Calendar
MCPs (gmailmcp.googleapis.com, etc.) — answer 200 to an anonymous
initialize, so OAuth scope and authorization-server discovery silently
failed and connections to them could not be established.

Run the discovery regardless of the probe's HTTP status: prefer the
resource_metadata URL from WWW-Authenticate when present, and otherwise
fall back to the RFC 9728 §4.2 well-known URIs. The trade-off is a couple
of extra well-known GETs during MCP connection setup for servers that
expose no PRM document; behavior for 401-responding servers is unchanged.
2026-06-29 04:34:36 -05:00
Timothy Jaeryang Baek f8ec63203c refac 2026-06-29 04:31:43 -05:00
Timothy Jaeryang Baek fd7a59d37a refac 2026-06-29 04:27:48 -05:00
G30 ea6d02da0f fix(ui): use correct field name for prompt clone title suffix (#25800) 2026-06-29 04:25:21 -05:00
Timothy Jaeryang Baek ab84bbf08c refac 2026-06-29 04:19:33 -05:00
Timothy Jaeryang Baek b58b0ea7ca refac 2026-06-29 04:13:17 -05:00
Timothy Jaeryang Baek d3676b4f71 refac 2026-06-29 04:11:46 -05:00
Timothy Jaeryang Baek c4688b958d refac 2026-06-29 04:03:06 -05:00
Timothy Jaeryang Baek 33b91bd8ae refac 2026-06-29 03:58:00 -05:00
Classic298andClaude 4c05abbe59 fix: respect model visibility in workspace base model selector (#25668)
The base model selector in the model creation workspace listed every
model from the global store, ignoring the per-model visibility (hidden)
flag. Models marked as not visible were correctly hidden from the chat
model selector but still appeared in the workspace 'Base Model (From)'
dropdown.

Filter out hidden models (info.meta.hidden) to match the chat selector
behavior, while still keeping a model visible if it is the currently
selected base, so editing a preset built on a now-hidden base does not
show a blank dropdown.

Fixes #25665

https://claude.ai/code/session_01CUvzYN9DjvwTpNCT5QL33B

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-29 03:51:26 -05:00
Classic298 0fc630b34b fix: per-user model cache used static key= (cross-user model exposure) (#25783)
routers/openai.py and routers/ollama.py decorated get_all_models with
`@cached(key=lambda _, user: ...)`. In aiocache 0.12, `key=` is a STATIC
cache key: get_cache_key returns `self.key` verbatim (the lambda object)
without calling it, so every caller collides to ONE shared entry within
the TTL. The intended per-user namespacing never happened — one user's
permission-filtered model list could be served to another user (or an
anonymous caller) during the cache window.

The per-call hook is `key_builder=` (called as key_builder(func, *args,
**kwargs)). Switch both sites to key_builder with a (func, request,
user=None) signature so the key is built per call. user=None mirrors
ollama's optional-user signature and stays correct whether user is passed
positionally, as a kwarg, or omitted.

Verified offline: old form returns the same key object for distinct users
(collision); new form yields distinct openai_all_models_<id> /
ollama_all_models_<id> keys, and the unauthenticated base key when no user.
These two were the only @cached(key=lambda ...) sites in the backend.
2026-06-29 03:51:02 -05:00
G30 ee11069ef2 perf(ollama): offload multi-GB model file I/O with asyncio.to_thread (#25829)
The Ollama model upload and download handlers perform three stages
of sync file I/O on multi-GB model files inside async handlers:

1. Persist upload — file.file.read() + write() in a loop
2. SHA-256 hash — calculate_sha256() reads the file sequentially
3. Read for blob push — open().read() loads entire model into memory

All three stages block the event loop for the duration of the I/O.
For a 4GB model, each stage freezes the event loop for 10+ seconds
while every other user's request stalls.

Wrap each blocking stage in asyncio.to_thread() in both handlers:

- upload_model(): persist upload, calculate_sha256, blob read
- download_file_stream(): calculate_sha256, blob read

Benchmark (full pipeline: write + SHA-256 + read back, 3 trials):
- 200MB: max jitter 145ms → 1ms (145x improvement)
- 500MB: max jitter 1,026ms → 1ms (1,026x improvement)

File I/O is pure I/O — no GIL contention. asyncio.to_thread()
completely eliminates event loop blocking.
2026-06-29 03:48:48 -05:00
G30 b05be8a907 fix(notes): refresh pinned notes sidebar when a note is deleted (#25640) 2026-06-29 03:48:22 -05:00
Classic298andClaude Opus 4.8 a66477b710 fix: bind channel thread parent/reply to the URL channel (#25766)
GET /api/v1/channels/{id}/messages/{message_id}/thread authorized only the URL channel, but get_messages_by_parent_id() appended the thread parent (loaded by id) without checking it belonged to that channel, so a caller could read a message from a channel they cannot access by passing its id as the thread root. Require the parent to be in the requested channel before returning it, and reject a posted parent_id/reply_to_id that does not belong to the channel.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 03:46:50 -05:00
G30 7b1aa749eb perf(retrieval): make URL fetch and file hash non-blocking with asyncio.to_thread (#25822)
- Wrap get_content_from_url() in asyncio.to_thread() inside
  get_sources_from_items() to prevent sync requests.get() from blocking
  the event loop when users attach URL sources to chat messages.
  The same function was already properly wrapped at retrieval.py:1839.

- Wrap hashlib.sha256(contents).hexdigest() in asyncio.to_thread()
  inside upload_file_handler() to prevent CPU-bound hashing from
  blocking the event loop during file uploads (44ms/100MB, scales
  linearly with file size).
2026-06-29 03:46:24 -05:00
Timothy Jaeryang Baek 958237473f refac 2026-06-29 03:42:49 -05:00
Timothy Jaeryang Baek a70a6589af refac 2026-06-29 03:42:36 -05:00
Timothy Jaeryang Baek 4bc4630721 refac 2026-06-29 03:40:18 -05:00
G30 a8e5f0a54d fix(ui): prevent space key from opening file/directory during rename in knowledge base (#25627) 2026-06-29 03:37:09 -05:00
maco 8bc4ac2641 fix: add aria-pressed and aria-label to Web Search and Image Generation toggles (#25258) 2026-06-29 03:36:37 -05:00
Classic298andClaude Opus 4.8 58f9170319 fix: trim terminal bearer token so whitespace can't break the WebSocket (#25686)
A trailing space in the Open Terminal bearer token broke only the
interactive terminal: HTTP calls put the token in the Authorization
header, where the spec strips trailing whitespace, so the connection
test, file listing and tool calls all worked. The terminal WebSocket
can't set headers from the browser, so it sends the token inside a JSON
auth message that preserves the space verbatim, failing auth with
[Connection closed]. Normalize the key on save so whitespace never
enters storage, and trim it in the WebSocket auth message so existing
saved configs work without re-saving.


Fixes #25613

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 03:35:19 -05:00
Classic298 e98730b20d fix: pass web search results to model when embedding & retrieval enabled (#25600)
Web search results stored as a vector collection were silently dropped
before retrieval when BYPASS_RETRIEVAL_ACCESS_CONTROL is False (the
default). The server-generated web_search file item carries a
'collection_name' but its 'web_search' type is not matched by any
explicit dispatch branch in get_sources_from_items, so it fell through
to the untrusted client-supplied collection_name branch and was ignored.

Add an explicit branch for type == 'web_search' items so the collection
is queried again. Access control is preserved: the collection still
passes through filter_accessible_collections, which already allowlists
web-search-* and bypasses only for admins.

Regression introduced when the retrieval access-control hardening gated
the bare collection_name fallback behind BYPASS_RETRIEVAL_ACCESS_CONTROL.
2026-06-29 03:34:55 -05:00
Timothy Jaeryang Baek 9802b0d135 refac 2026-06-29 03:33:49 -05:00
G30 ed2d7d4acd fix(i18n): standardize title case for settings toggle labels (#25765) 2026-06-29 03:30:40 -05:00
Timothy Jaeryang Baek f85e906dec refac 2026-06-29 03:28:50 -05:00
G30 70b89d01c2 feat(ui): add drag-to-reorder for pinned notes in sidebar (#25677) 2026-06-29 03:22:33 -05:00
Timothy Jaeryang Baek 3fd0384ffc refac 2026-06-29 03:21:41 -05:00
G30 78d276b4ff fix(ui): update groups count to reflect filtered search results (#25689) 2026-06-29 03:21:31 -05:00
Classic298andClaude Opus 4.8 5796d44363 fix: defer missing-local-embedding error so it can't crash boot (#25683)
get_embedding_function runs at import time (main.py), so the empty-engine
+ no-model guard added in 55ca719b raised ValueError during startup and
bricked the instance: a blank embedding model set via the UI made the app
unbootable, with no way back into settings to undo it. Move the check into
the returned coroutine so construction always succeeds and the error only
fires when something actually embeds, surfacing as a clear request error
instead of the old cryptic 'NoneType has no attribute encode'.


Fixes #25634
Fixes #25165

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 03:21:09 -05:00
G30 6e14e446cb fix(api): handle orphaned shared_chat rows when unsharing (#25632) 2026-06-29 03:20:46 -05:00
Timothy Jaeryang Baek c93d4f04aa refac 2026-06-29 03:20:02 -05:00
Timothy Jaeryang Baek 33cd199e6d refac 2026-06-29 03:16:59 -05:00
G30 4712544d5e feat(search): add chat context menu to search modal (#25490) 2026-06-29 03:16:03 -05:00
maco 958fdbdc88 feat: add ariaLabel prop to Switch component (#25230) 2026-06-29 03:15:11 -05:00
Timothy Jaeryang Baek 390e200f76 refac 2026-06-29 03:13:17 -05:00
Kylapaallikko 462b66b807 Update fi-FI translation.json (#25672)
Added missing translations and improved existing ones.
2026-06-29 03:07:49 -05:00
Timothy Jaeryang Baek 388f62f8a0 refac 2026-06-29 03:03:36 -05:00
Timothy Jaeryang Baek 7be009649a refac 2026-06-29 02:57:58 -05:00
Classic298andClaude Opus 4.8 534206095f fix: route Account Pending sign out through backend signout flow (#25681)
The Sign Out button on the Account Pending overlay only cleared the
local token and redirected to /auth, skipping the backend signout. This
left the OAuth/OIDC session alive at the IdP, so re-login bounced the
user straight back into pending and never re-fetched updated role/group
claims. Call userSignOut() and honor the returned redirect_url, matching
the sidebar logout flow.


Fixes #25644

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:54:19 -05:00
G30 525c115a3a fix(ui): remove unnecessary margin causing scrollbar on About settings page (#25802) 2026-06-29 02:54:00 -05:00
Timothy Jaeryang Baek b34d6c836e refac 2026-06-29 02:50:55 -05:00
G30andTim Baek 6fdf9b4340 perf(auth): make password hashing non-blocking and batch CSV user import (#25804)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-06-29 02:45:39 -05:00
G30 18e6a10778 fix(ui): update model share URL to new community post endpoint (#25801) 2026-06-29 02:44:28 -05:00
Classic298andClaude Opus 4.8 36d08fa2a7 fix: request file list/search without extracted content by default (#25774)
The file picker and Files modal only render filenames and metadata, but
searchFiles()/getFiles() never passed content=false, so the backend
serialized data.content (full extracted text) of every file into each
response. On instances with many large documents this turns a metadata
list into tens of MB per request, re-issued on every picker keystroke.

The backend already supports content=false on GET /files/ and
GET /files/search; this just makes the list/search callers opt out of the
payload by default. The param stays overridable for callers that need it.


Fixes #25741

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:43:56 -05:00
Timothy Jaeryang Baek 2bdd2ab94e refac 2026-06-29 02:43:14 -05:00
Classic298andClaude Opus 4.8 2414dfca70 fix: mark in-place created chats as read on new chat (#25782)
A chat created in place (new chat -> first message) keeps chatIdProp
undefined because the URL is swapped via history.replaceState without a
remount, so none of the chatIdProp-gated updateLastReadAt paths fire and
the chat is never persisted with a last_read_at. Starting another new
chat via initNewChat reset $chatId without marking the outgoing chat
read, so on refresh updated_at > last_read_at (NULL) and the chat shows
as unread in the sidebar.

Mark the outgoing chat read in initNewChat, mirroring navigateHandler.


Fixes #25108

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:32:00 -05:00
G30andTim Baek 6ea591491e perf(images): offload validate_url() DNS resolution with asyncio.to_thread (#25825)
validate_url() calls socket.getaddrinfo() for SSRF protection, which
blocks the event loop for 100-700ms per DNS lookup. This affects:

- Image generation (get_image_data) — every external image URL
- Image editing (load_url_image) — every external image URL
- OAuth profile pictures (_process_picture_url) — every login
- Webhook delivery (post_webhook) — every notification
- Image base64 conversion (get_image_base64_from_url) — chat images

Wrap all 5 async call sites in asyncio.to_thread() so DNS resolution
runs in the thread pool. The event loop remains free to serve other
requests during the lookup.

Benchmark (3 domains, 3 trials averaged):
- BEFORE: max jitter 479ms, 1 blocked ping per trial
- AFTER:  max jitter 1ms, 0 blocked pings (324x improvement)

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-06-29 02:31:24 -05:00
Aleix Dorca d4d9786434 i18n: Update catalan translation.json (#25595) 2026-06-29 02:27:11 -05:00
Timothy Jaeryang Baek ac3449cac9 refac 2026-06-29 02:26:27 -05:00
G30 71d6212ab8 fix(redis): use await asyncio.sleep() instead of time.sleep() in async generator (#25823)
time.sleep() inside the _wrap_async_gen() async generator blocks the
entire event loop during Sentinel failover retries. The sibling method
_wrap_async_call() already correctly uses await asyncio.sleep().
2026-06-29 02:25:46 -05:00
Timothy Jaeryang Baek 2308b59f13 refac 2026-06-29 02:21:24 -05:00
Timothy Jaeryang Baek 61a2672215 refac 2026-06-29 02:20:54 -05:00
Classic298andClaude Opus 4.8 386ac95814 fix: scope Socket.IO event-caller to the requesting user's own session (#25763)
get_event_call() routed execute:python / execute:tool events to a client-supplied session_id after only checking the session was connected, not that it belonged to the requester. Verify the target session is owned by the requesting user (metadata user_id) before delivering, so a client cannot route code/tool execution into another user's session.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:17:40 -05:00
914039ac81 Escape voice-derived attributes in Azure TTS SSML (#25776)
The Azure TTS handler (_tts_azure) interpolated the user-supplied voice,
and the locale derived from it, into the SSML xml:lang and <voice name>
attributes without XML-escaping, while the text body was already escaped
(2e75c6dbd). Escape both attributes too, so every user-derived value in
the SSML document is consistently encoded.

Co-authored-by: alanturing881 <alanturing881@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:16:37 -05:00
G30 ff25ccca65 feat: add repeat/recurrence dropdown to calendar event modal (#25865)
* feat(calendar): add repeat/recurrence dropdown to event modal

* fix(calendar): anchor recurring event expansion to event start date

The expand_recurring_event() utility used the view range start as dtstart,
causing FREQ=WEEKLY events to land on the wrong day of the week. Use the
event's original start date instead so recurrence patterns stay correct.
2026-06-29 02:16:19 -05:00
Classic298andClaude Opus 4.8 01198eaeef Close DNS-rebinding SSRF gap in get_content_from_url probe (#25775)
The web-ingest probe in get_content_from_url validated the URL at resolve
time (validate_url) but then fetched with a bare requests.get, which
re-resolves the hostname at connect time. An attacker-controlled name
server can answer with a public IP during validation and an internal IP
at connect, reaching cloud metadata / loopback / internal services (blind
always; binary content-types are read back to the caller). The
connection-layer guard (#24759) that closes this for the SafeWebBaseLoader
path was never mounted on this probe.

Route the probe through the same _SSRFSafeAdapter the loader uses, so the
resolution that feeds the TCP connect is re-validated against the
global-IP check.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:15:31 -05:00
Classic298 15d96b1f2a fix: chroma has_collection always returns False (name vs Collection) (#25780)
has_collection did `collection_name in self.client.list_collections()`,
but chromadb's list_collections() returns Collection objects (1.x), not
name strings — so the membership test is always False, even when the
collection exists. Compare against the collection names instead (with a
hasattr guard tolerating versions that yield plain names).

Found via the dependency-contract test suite (unit/deps/test_chromadb.py).
2026-06-29 02:15:05 -05:00
G30 342539f1e1 perf(audio): make ML model loading non-blocking with asyncio.to_thread (#25806) 2026-06-29 02:14:15 -05:00
Classic298andClaude Opus 4.8 c31694af09 chore: Update SECURITY.md (#25773)
* Update SECURITY.md

* Update SECURITY.md

* Update SECURITY.md

* Update SECURITY.md

* Update SECURITY.md

* Update SECURITY.md

* Update SECURITY.md

* Update SECURITY.md

* Extend the already-fixed/monitoring rule to public PRs and credit

Broaden the rule from "already fixed" to also cover issues already being fixed in
the open (e.g. an open pull request), extend the commit-monitoring pattern to PRs,
and fold in the credit consequence on provable grounds rather than an unprovable
bad-faith claim: a report of an already-public, already-fixed-or-being-fixed issue
filed strictly last is a duplicate we cannot distinguish from scraping, so it earns
no advisory. Credit belongs to whoever found or fixed it, who forfeits it by
disclosing publicly instead of reporting confidentially first — so a public fix
earns no advisory and no credit for anyone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Remove Rule 14 (One Vulnerability Per Report)

The one-CVE-per-vulnerability constraint it restated is a CVE Program counting
rule, already binding through the "Alignment with the CVE Program" section.
Dropping the standalone rule removes the duplication; bundled reports are still
split on that basis when they arise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* security policy: surface "What a Valid Report Gets You" near the top, refresh date

Move the "What a Valid Report Gets You" section up to directly under the good-faith
reporting section (it leads with what reporters receive, rather than burying it
below the rules), and update the last-updated date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Update SECURITY.md

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:11:17 -05:00
G30 964a098a4b fix(chat): use code block language tag to detect Vega-Lite specs for rendering (#25843) 2026-06-29 02:07:11 -05:00
Classic298 c3394288bb fix: repair Mistral OCR async upload (aiohttp.streams.FilePayload removed) (#25779)
_upload_file_async built its multipart body with
`aiohttp.streams.FilePayload(...)`, which no longer exists in aiohttp
(payload classes live in aiohttp.payload, and there is no FilePayload).
The reference sits inside a lazy closure, so import succeeds and only the
async Mistral OCR file-upload path blows up at runtime with
AttributeError on every call.

Mirror the working sync path: open the file in a context manager and let
MultipartWriter.append(file_obj, {...}) build a streaming
BufferedReaderPayload, with the POST issued inside the open() block so
the handle stays valid for the whole upload. Preserves the streaming /
memory-efficiency intent.

Found via the dependency-contract test suite (unit/deps/test_aiohttp.py),
which pins aiohttp.streams.FilePayload as absent.
2026-06-29 02:05:54 -05:00
Classic298 8a016931f1 refac(telemetry): drop deprecated semconv SpanAttributes subclass (#25784)
constants.py subclassed opentelemetry.semconv.trace.SpanAttributes, which
is deprecated since semconv 1.25.0 (emits a DeprecationWarning; the pinned
0.63b1 has it live). Source the legacy span-attribute keys from the
non-deprecated _incubating attribute modules instead:
  http.url / http.method / http.status_code  <- _incubating http_attributes
  db.name / db.statement / db.operation      <- _incubating db_attributes
The stable http module renamed these (http.request.method, ...), so only
the incubating module preserves the original values. Custom keys
(db.instance/type/ip/port, error.*, result.*) stay literals.

Verified: emitted attribute keys are byte-identical before/after for every
key instrumentors.py reads, and importing constants no longer emits a
DeprecationWarning.
2026-06-29 02:05:34 -05:00
G30 e69ce6e1c6 perf(channels): batch N+1 queries for reactions and thread replies (#25831)
Replace per-message database queries with batch IN-clause queries in
channel message handlers. This eliminates the N+1 query pattern that
caused ~102 queries per channel page load (50 messages × 2 queries each).

Changes:
- Add get_reactions_by_message_ids() to MessageTable: single query
  fetches all reactions for multiple messages using IN clause with
  User JOIN, returns dict[message_id, list[Reactions]]
- Add get_thread_reply_counts_by_message_ids() to MessageTable: single
  GROUP BY aggregate query returns (count, max_created_at) per parent,
  replacing full object loads just to call len()
- Refactor get_channel_messages(): 102 → 4 queries per page
- Refactor get_pinned_channel_messages(): 22 → 3 queries per page
- Refactor get_channel_thread_messages(): 53 → 4 queries per page
- Refactor send_notification(): N+1 membership check → batch set lookup
2026-06-29 02:05:16 -05:00
Timothy Jaeryang Baek 6050a94d77 refac 2026-06-29 02:03:58 -05:00
Classic298 5fd26b7549 docs: note pydub/audioop Python 3.13 constraint at the import (#25785)
pydub imports the stdlib `audioop`, removed in Python 3.13, so audio
preprocessing would break there. requires-python is already capped at
< 3.13; this one-line pointer flags what to handle (audioop-lts, or drop
pydub) before raising that cap.
2026-06-29 02:03:11 -05:00
G30 2a9a023172 fix(ui): add Edit option to prompts 3-dots menu (#25789) 2026-06-29 02:02:39 -05:00
Classic298andClaude Opus 4.8 b295a20b9d chore: bump Python backend dependencies, drop unused peewee (#25786)
* chore: bump Python backend dependencies, drop unused peewee

Minor/patch + reviewed major bumps across requirements.txt,
requirements-min.txt, pyproject.toml and uv.lock; playwright image bumped in
docker-compose.playwright.yaml. peewee/peewee-migrate removed (zero imports).

Security-relevant: cryptography 46->48, authlib 1.6.10->1.7.2, PyJWT 2.11->2.13,
requests 2.33.1->2.34.2, RestrictedPython 8.1->8.2, pillow 12.1.1->12.2.0.
Reviewed majors: redis 7->8, pymilvus ->2.6.14, azure-search-documents 11->12,
chardet 5->7, unstructured 0.18->0.22, pycrdt 0.12->0.13.

Testing:
- Resolution: `uv lock` resolves the full bumped set with no conflicts; uv.lock
  regenerated to match (peewee dropped, every pin including
  azure-search-documents==12.0.0 resolves).
- Per-dependency contract tests (external tests repo, unit/deps/): 105 files,
  2205 passed / 6 skipped, ruff-clean. One file per dependency pins the symbols,
  signatures and behaviour the backend actually uses, so an API removal/rename in
  a bumped version fails loudly instead of at runtime. Offline/deterministic.
- End-to-end embed->retrieve test driving transformers + sentence-transformers +
  chromadb together through Open WebUI's real RAG path (cached model, in-memory
  chroma, semantic retrieval asserted).
- Install/startup/health resolution gate added to the dep-bump workflow and the
  integration suite (uv/pip resolve + uvicorn /health + Playwright dev visibility).
- Bugs surfaced while testing each got an isolated fix branch + regression test:
  Mistral OCR aiohttp FilePayload (#25779), chroma has_collection (#25780),
  aiocache per-user model-cache key (security), otel semconv deprecation,
  pydub/audioop <3.13 note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Bump python-multipart 0.0.22 -> 0.0.27 (CVE-2026-42561, CVE-2026-40347)

0.0.22 is affected by two DoS CVEs in the multipart parser that
Starlette/FastAPI run for every multipart/form-data request, so any
authenticated user hitting an upload endpoint can trigger them:
- CVE-2026-42561: unbounded part-header count/size -> CPU exhaustion (fixed 0.0.27)
- CVE-2026-40347: large multipart preamble/epilogue DoS (fixed 0.0.26)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:02:18 -05:00
G30 f923edcaaa feat(ui): add Clone option to automations 3-dots menu (#25790) 2026-06-29 02:00:23 -05:00
Timothy Jaeryang Baek c8260745a6 refac 2026-06-29 01:59:06 -05:00
G30 3c67774eb3 fix(auth): enforce settings.interface permission on /user/settings/update endpoint (#25996) 2026-06-29 01:53:20 -05:00
Timothy Jaeryang Baek ce4a323f43 refac 2026-06-29 01:52:07 -05:00
Timothy Jaeryang Baek b7934e9182 refac 2026-06-29 01:45:50 -05:00
Timothy Jaeryang Baek 46c1d6591b refac 2026-06-29 01:41:43 -05:00
Timothy Jaeryang Baek 3730a9eaac refac 2026-06-29 01:38:41 -05:00
Classic298andClaude Opus 4.8 a4f7ec1fb3 Chore: Add a security-reporting link to the issue chooser (#26007)
* Add a security-reporting link to the issue chooser

With blank issues disabled, the New Issue chooser only offered Bug Report and
Feature Request, leaving security reporters no obvious route and nudging them
toward filing vulnerabilities as public issues. Add a contact_links entry that
points to the Security Policy (/security/policy), where the "Report a
vulnerability" button opens a private GitHub advisory — keeping security
reports out of public issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Update config.yml

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:36:44 -05:00
G30 677e164f29 feat(permissions): add workspace.skills_import and workspace.skills_export permissions (#25921) 2026-06-29 01:36:27 -05:00
G30 da0fd0da0d fix(ui): add max height and scroll to chat menu tags section (#26031) 2026-06-29 01:36:03 -05:00
Timothy Jaeryang Baek 1da3b7f7e8 refac 2026-06-29 01:32:27 -05:00
Timothy Jaeryang Baek 8c57cfa645 refac 2026-06-29 01:32:09 -05:00
Timothy Jaeryang Baek 00924fbf79 refac 2026-06-29 01:32:05 -05:00
Timothy Jaeryang Baek 9df25b6932 refac 2026-06-29 01:31:31 -05:00
Timothy Jaeryang Baek 452954ff1e refac 2026-06-29 01:28:05 -05:00
Timothy Jaeryang Baek ec8e20af35 refac 2026-06-29 01:22:14 -05:00
Classic298andClaude Opus 4.8 89629b8f03 perf: make ToolCallDisplay parseJSONString iterative (#26146)
Replace the recursive parseJSONString with an equivalent iterative unwrap.
The recursion re-parsed its own already-parsed result until JSON.parse threw;
on scalar JSON values (e.g. "5" -> 5) that recursed until a stack overflow
which was then silently caught — wasted work on every complete tool-call
payload. The loop returns the identical value in all cases (verified
byte-identical across 29 inputs incl. double/triple-encoded and partial JSON)
without the stack churn.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:08:59 -05:00
Timothy Jaeryang Baek 7240517807 refac 2026-06-29 01:01:27 -05:00
G30 0502494e9a fix(ui): fix keyboard shortcuts modal Enter key emoji and menu capitalization (#26173)
- Append Unicode text variation selector U+FE0E to the ↩ character in
  ShortcutItem.svelte to prevent iOS/iPadOS rendering it as a colorful
  emoji instead of a plain text glyph.
- Fix 'Keyboard shortcuts' → 'Keyboard Shortcuts' (capital S) in the
  user menu to match the modal title and standard title case.
2026-06-29 00:59:07 -05:00
Hrushikesh Yadav 1f6336fd98 fix: strip whitespace from user info headers to prevent MCP connection failures (#26182) 2026-06-29 00:57:07 -05:00
alvarellos 368b4a5b22 solve-valves-icon-disappear-issue (#26256) 2026-06-29 00:56:44 -05:00
Timothy Jaeryang Baek b308391527 refac 2026-06-29 00:47:49 -05:00
Timothy Jaeryang Baek b854eb09b1 refac 2026-06-29 00:46:45 -05:00
Algorithm5838 62b153749a perf: skip re-sanitizing unchanged HTML tokens (#26325) 2026-06-29 00:43:54 -05:00
Timothy Jaeryang Baek 7292cee868 refac 2026-06-29 00:42:39 -05:00
Timothy Jaeryang Baek bc70696f4f refac 2026-06-29 00:40:28 -05:00
Timothy Jaeryang Baek dbdcfd8c60 refac 2026-06-29 00:35:54 -05:00
Timothy Jaeryang Baek 7e13fd7ad1 refac 2026-06-29 00:26:35 -05:00
Timothy Jaeryang Baek 124c7a3283 refac 2026-06-29 00:21:37 -05:00
Timothy Jaeryang Baek cfb49c4c18 refac 2026-06-29 00:19:47 -05:00
Timothy Jaeryang Baek 2560533c1a refac 2026-06-29 00:18:40 -05:00
Timothy Jaeryang Baek 5b1c42e81a refac 2026-06-29 00:05:10 -05:00
6f5f263244 fix: ignore IME composition Enter in chat search (#26172) (#26238)
Pressing Enter to confirm an IME composition (e.g. Japanese, Chinese, or Korean)
in the chat search modal and the sidebar search input was treated as a normal
Enter and triggered a search action such as starting a new chat, instead of just
confirming the composed text.

Guard the search keydown handlers so a keydown fired while composing is ignored:
- SearchInput.svelte on:keydown
- SearchModal.svelte document-level onKeyDown
using e.isComposing (with a keyCode === 229 fallback), mirroring the IME guard
already used by MessageInput. A second Enter, after the composition is confirmed,
still triggers the action as before.

Fixes #26172

Co-authored-by: Yogi <251807336+ybnjm1234-source@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 23:47:02 -05:00
Timothy Jaeryang Baek 5922727402 refac 2026-06-28 23:42:04 -05:00
Timothy Jaeryang Baek 03a8363583 refac 2026-06-28 23:28:40 -05:00
Timothy Jaeryang Baek 97901220f2 refac 2026-06-28 23:28:03 -05:00
Timothy Jaeryang Baek 8977a10a2b refac 2026-06-28 23:24:24 -05:00
joaoback cd1ec31957 i18n: add pt-BR translations for newly added UI items and consistency pass (#26318)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-06-28 23:23:43 -05:00
Timothy Jaeryang Baek ef8c9c063c refac 2026-06-28 23:22:10 -05:00
Timothy Jaeryang Baek dd4f43bfdb refac 2026-06-28 23:21:05 -05:00
Timothy Jaeryang Baek 3a232f5e9a refac 2026-06-28 23:20:54 -05:00
Timothy Jaeryang Baek 23d03d6aae refac 2026-06-28 23:10:14 -05:00
Timothy Jaeryang Baek d99ac7d3f8 refac 2026-06-28 23:02:38 -05:00
Timothy Jaeryang Baek c7be66626f refac 2026-06-28 22:53:47 -05:00
Timothy Jaeryang Baek 464e703e47 refac 2026-06-28 22:50:31 -05:00
Timothy Jaeryang Baek 518702caae refac 2026-06-28 22:35:59 -05:00
Timothy Jaeryang Baek 62ae206918 refac 2026-06-28 22:35:44 -05:00
Timothy Jaeryang Baek 516051304e refac 2026-06-28 22:33:59 -05:00
Timothy Jaeryang Baek 0130b49514 refac 2026-06-28 22:32:10 -05:00
Algorithm5838 5aeb1ca708 fix: persist control edits across navigation (#26336) 2026-06-28 22:16:46 -05:00
Timothy Jaeryang Baek df634bb64f refac 2026-06-28 22:11:01 -05:00
Algorithm5838 6729e64f30 perf: skip re-splitting unchanged text tokens (#26326) 2026-06-28 22:09:58 -05:00
Timothy Jaeryang Baek ea3f5f22d2 refac 2026-06-28 22:09:15 -05:00
Aindriú Mac Giolla Eoin 0b0910bee2 i18n: update Irish translation (#26360) 2026-06-28 22:02:11 -05:00
Timothy Jaeryang Baek 7c3802a55e refac 2026-06-28 21:59:19 -05:00
G30 08f64f7908 fix(calendar): prevent all-day event date drift on save (#25864) 2026-06-25 18:40:25 -04:00
Timothy Jaeryang Baek e3ba698453 refac 2026-06-25 17:34:41 -04:00
Timothy Jaeryang Baek 741b64edb6 refac 2026-06-25 17:23:53 -04:00
Timothy Jaeryang Baek 7c0b0e42f5 refac 2026-06-25 17:14:22 -04:00
Timothy Jaeryang Baek 8f890f0b43 refac 2026-06-25 15:56:10 +01:00
Timothy Jaeryang Baek ede39d82de refac 2026-06-25 15:49:36 +01:00
Timothy Jaeryang Baek 1a8e1a9939 refac 2026-06-25 15:22:31 +01:00
Timothy Jaeryang Baek 7b55a63fc7 refac 2026-06-25 15:22:20 +01:00
Timothy Jaeryang Baek 7d9b249671 refac 2026-06-25 14:40:35 +01:00
Timothy Jaeryang Baek e124c2656a refac 2026-06-25 14:37:05 +01:00
Timothy Jaeryang Baek 5576e6ed8a refac 2026-06-25 14:34:22 +01:00
Timothy Jaeryang Baek 7453968678 refac 2026-06-25 14:19:30 +01:00
Timothy Jaeryang Baek 47a1bfdd15 refac 2026-06-25 14:19:21 +01:00
Timothy Jaeryang Baek b5c43968db refac 2026-06-25 03:31:45 +01:00
Timothy Jaeryang Baek 35f8bf97e3 refac 2026-06-25 03:26:31 +01:00
Timothy Jaeryang Baek 1457f2dec8 refac 2026-06-25 03:26:17 +01:00
Timothy Jaeryang Baek 1111a3a222 refac 2026-06-25 03:26:07 +01:00
Timothy Jaeryang Baek f812072215 refac 2026-06-25 03:24:50 +01:00
Timothy Jaeryang Baek 8934bfb04b refac 2026-06-24 14:13:58 +02:00
Timothy Jaeryang Baek fd56086e79 refac 2026-06-24 13:19:55 +02:00
Timothy Jaeryang Baek 95391221df refac 2026-06-23 23:35:44 +02:00
Timothy Jaeryang Baek 19db873603 refac 2026-06-23 23:35:40 +02:00
Timothy Jaeryang Baek 7f08376f0c refac 2026-06-23 23:18:05 +02:00
Timothy Jaeryang Baek 15c7e37438 refac 2026-06-23 23:13:32 +02:00
Timothy Jaeryang Baek b1c2536ed2 refac 2026-06-23 23:13:28 +02:00
Timothy Jaeryang Baek 91762ed807 refac 2026-06-23 00:25:21 +02:00
Timothy Jaeryang Baek a0c2ec3d2c refac 2026-06-22 17:02:14 +02:00
Timothy Jaeryang Baek 7e8153e889 refac 2026-06-22 16:47:48 +02:00
Timothy Jaeryang Baek 223f484ded refac 2026-06-22 16:10:19 +02:00
Timothy Jaeryang Baek 88901bfa04 refac 2026-06-22 14:42:23 +02:00
Timothy Jaeryang Baek 928eb015bd refac 2026-06-19 16:28:25 +02:00
Timothy Jaeryang Baek a54878b14f refac 2026-06-19 15:34:43 +02:00
Timothy Jaeryang Baek 8b9e28b503 refac 2026-06-19 15:28:52 +02:00
Timothy Jaeryang Baek 3f0c0e0a0d refac 2026-06-19 00:16:06 +02:00
Timothy Jaeryang Baek 8958b64b5a refac 2026-06-18 11:02:14 +02:00
Timothy Jaeryang Baek 21f9e5295b refac 2026-06-18 10:47:25 +02:00
_00_ 0ffc04797f i18n: Update es-ES Spanish translations v0.9.6 (#25836)
i18n: Update Spanish translations v0.9.6

Update of new strings
2026-06-17 03:09:26 +02:00
G30 b2809e6293 perf(ui): hoist marked.use() to module scope to prevent extension stacking (#25837) 2026-06-17 03:09:09 +02:00
G30 beb9bf60e4 feat(memory): default user memory toggle to admin's global ENABLE_MEMORIES config (#25909) 2026-06-17 03:08:43 +02:00
G30 2f9b28a57d fix(ui): run initChatList API calls concurrently in Promise.all (#25838) 2026-06-17 03:08:02 +02:00
Classic298 d501e3d6b5 Update milvus_multitenancy.py (#25857) 2026-06-17 03:07:27 +02:00
G30 819ad1d904 fix(chat): convert recursive findClosingDelimiter to iterative loop to prevent stack overflow (#25845) 2026-06-17 03:07:12 +02:00
G30 9fe3a00dba perf(chat): cache KaTeX rendered HTML in reactive statement to avoid redundant renders (#25847) 2026-06-17 03:06:56 +02:00
G30 4584adf900 feat(ui): add 'Unshare All Shared Chats' button to Shared Chats modal (#25848) 2026-06-17 03:06:41 +02:00
G30 dfdb76cc46 fix(ui): prevent Code Editor drawer from collapsing when content is empty (#25855) 2026-06-17 03:06:15 +02:00
17df026492 Confer object-derived file write only for files the object owner owns (#26032)
has_access_to_file() derives file access from the objects a file is attached to
(knowledge bases, workspace models). Those branches returned True for any access_type
whenever the user held that permission on the object, write/delete included. Since a
user can create their own KB or model and attach any file they can merely READ (KB
attach and the model meta.knowledge validator both gate on read access only), a user
with read access to a victim file could launder it into write/delete: attach it to an
object they own, then rename, overwrite or delete it via the write-gated file routes
(POST /files/{id}/rename, /data/content/update, DELETE /files/{id}). This is the
residual of GHSA-vjqm-6gcc-62cr (CVE-2026-54012) left open by the read-only attach
validator (CWE-863).

An object now confers write/delete on a file only when the object's owner owns that
file, so delegation originates from the file's own owner. Read is unchanged (RAG and
shared-object reads still work), and legitimate delegation is preserved: a write grant
on an object whose owner owns the attached file still confers write. Applied to all
three object branches: knowledge base, file home collection, and workspace model.

Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:05:57 +02:00
e038bab66d Authorize POST /api/v1/images/edit (enforce ENABLE_IMAGE_EDIT + image-gen permission) (#26009)
The direct image-edit route was the only image-edit surface with no authorization: it ran
on get_verified_user alone, while POST /generations enforces ENABLE_IMAGE_GENERATION +
features.image_generation and the built-in edit_image tool enforces ENABLE_IMAGE_EDIT +
features.image_generation. A verified non-admin user could therefore reach the configured
image-edit provider (spending IMAGES_EDIT_OPENAI_API_KEY) even when the administrator had
globally disabled image editing (ENABLE_IMAGE_EDIT=False) or denied the user image
generation.

Split the route from the shared impl (mirroring generate_images/image_generations): the new
/edit wrapper enforces ENABLE_IMAGE_EDIT and the per-user image-generation permission, then
delegates to image_edits(). The internal callers (the edit_image tool and the chat
middleware) already gate themselves and call image_edits() directly, so they are unaffected.

Co-authored-by: jagstack <52110932+jagstack@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:05:41 +02:00
Classic298 c39be0e2d6 Update milvus.py (#25858) 2026-06-17 03:05:27 +02:00
Taylor WilsdonandTim Baek 6cd0ba0b6b implement date picker and additional columns for analytics (#25922)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-06-17 03:03:18 +02:00
Timothy Jaeryang Baek e473ab1231 refac 2026-06-17 03:01:11 +02:00
Timothy Jaeryang Baek 4dbb2f94a6 refac 2026-06-17 02:58:11 +02:00
G30 dee07d8a30 feat(ui): show total archived chat count in ChatsModal title (#25872) 2026-06-17 02:57:27 +02:00
Algorithm5838 e031fadc35 perf: skip highlight.js auto-detection in read-only code blocks (#25824) 2026-06-17 02:57:02 +02:00
G30 b4aef82401 feat(ui): show total file count in FilesModal title (#25873) 2026-06-17 02:55:13 +02:00
Timothy Jaeryang Baek 5cdcdbaeec refac 2026-06-17 02:52:35 +02:00
Timothy Jaeryang Baek e8d55c0a8b refac 2026-06-17 02:36:20 +02:00
G30 57a5e43696 feat(permissions): add per-group features.user_webhooks permission (#25923) 2026-06-17 00:47:44 +02:00
Timothy Jaeryang Baek 7b29834d42 refac 2026-06-17 00:43:59 +02:00
G30 2b9b956dad fix(notes): sync pinned note state in sidebar and editor menu (#25918) 2026-06-17 00:42:20 +02:00
G30 25090dbf17 fix(chat): prevent false-positive unread indicators from title generation, pin/archive/folder actions, and new chat creation (#25912) 2026-06-17 00:41:44 +02:00
G30 bb2663aad1 fix(ui): gate all chat export formats by chat.export permission (#25914) 2026-06-17 00:41:18 +02:00
G30 3571db34e2 fix(ui): enforce allow_users permission in share chat modal (#25915) 2026-06-17 00:40:57 +02:00
Timothy Jaeryang Baek 7d1f941580 refac 2026-06-17 00:40:04 +02:00
Timothy Jaeryang Baek ed4cb358a0 refac 2026-06-17 00:39:11 +02:00
Timothy Jaeryang Baek caedcbae49 refac 2026-06-17 00:36:34 +02:00
Timothy Jaeryang Baek 9ccda6715c refac 2026-06-17 00:35:08 +02:00
Timothy Jaeryang Baek edf3ae9209 refac 2026-06-17 00:33:30 +02:00
G30 0726db7217 fix(analytics): remove duplicate onMount causing double API requests on dashboard load (#25935) 2026-06-17 00:29:07 +02:00
G30 9c6c375dfe fix(images): add null guard for config in onMount to prevent crash on API failure (#25933) 2026-06-17 00:28:42 +02:00
G30 3e3c5b6d78 fix(permissions): add admin bypass to memories permission checks (#25924) 2026-06-17 00:28:17 +02:00
G30 2bc91e8f52 fix(ui): add missing skills permission to workspace menu visibility check (#25925) 2026-06-17 00:27:25 +02:00
G30 e5ed45fb20 fix(ui): handle null chat in folder drop handler for cross-account drag (#25928) 2026-06-17 00:26:35 +02:00
Timothy Jaeryang Baek 232421f40b refac 2026-06-17 00:25:35 +02:00
G30 6b2d962cd6 feat(permissions): add reset to defaults button in permissions modals (#25931)
Adds a 'Reset to Defaults' button to both the Edit Default Permissions
modal and the Edit User Group permissions modal.

- Default permissions modal: resets to stock/env-var configuration
- User group modal: resets to current global default permissions

Backend: new GET /api/v1/users/default/permissions/defaults endpoint
that returns DEFAULT_USER_PERMISSIONS (env-var-based initial defaults).
2026-06-17 00:25:20 +02:00
Timothy Jaeryang Baek 7ee75a0c04 refac 2026-06-17 00:17:48 +02:00
Timothy Jaeryang Baek fc9c2ea191 refac 2026-06-17 00:17:14 +02:00
G30 6f2e97aa58 fix(evaluations): prevent duplicate leaderboard API requests on navigation (#25934) 2026-06-17 00:16:27 +02:00
5f3a628a8d Encode terminal ws session_id to block upstream user_id query injection (#26042)
ws_terminal() interpolated the path parameter session_id directly into the upstream
terminal WebSocket URL and then appended ?user_id=<caller>, with no encoding or
validation (the HTTP sibling proxy_terminal runs _sanitize_proxy_path; this path ran
nothing). An encoded '?'/'&' smuggled through session_id survives Open WebUI's single
decode and is re-decoded by the upstream, injecting an attacker-chosen user_id ahead
of the appended one. Query parsing binds the first occurrence, so the orchestrator
resolves the spoofed user's terminal scope, letting a normal authenticated user
present another user's identity to the upstream (CWE-116/863).

Encode session_id as an opaque path segment with urllib.parse.quote(session_id,
safe=''). This neutralises '?'/'#'/'&' at any decode depth (the upstream's single
decode reverses only the quote, leaving the original delimiters inert as path
content), while legitimate UUID session ids pass through unchanged. The appended
user_id is then the only query parameter the upstream binds.

The separate concern that the forwarded identity is a bearer claim with no integrity
binding spans Open WebUI and the upstream terminal server and is not addressed here.

Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:15:39 +02:00
Timothy Jaeryang Baek 8b35ce924b refac 2026-06-17 00:15:04 +02:00
G30 966b3fdb57 fix(ui): deduplicate layout-level API requests (#25942) 2026-06-17 00:09:50 +02:00
G30 ee3a49a88d fix(ui): deduplicate store fetches with null-guards and mutex (#25943) 2026-06-17 00:07:11 +02:00
Classic298andClaude Opus 4.8 22f2fe1ffb Require auth on ydoc:awareness:update and ydoc:document:leave handlers (CWE-306) (#25946)
These were the only two ydoc Socket.IO handlers missing the SESSION_POOL guard that
every sibling (join/state/update) enforces. With always_connect=True admitting
unauthenticated sockets, a client that knew a note's document_id could broadcast
spoofed awareness (cursors/presence) and fake ydoc:user:left events into a live
editing room, attributed to any client-supplied user_id.

Both handlers now require an authenticated SESSION_POOL session; the awareness handler
additionally requires prior ydoc:document:join (room membership); and the broadcast
user_id is fixed to the authenticated identity instead of the client-supplied value,
removing the impersonation. Document content was never reachable (ydoc:document:update
already enforces write permission).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:06:13 +02:00
Timothy Jaeryang Baek 993e749121 refac 2026-06-17 00:05:45 +02:00
4c06b392da Don't let SCIM's active flag demote an admin (defense-in-depth) (#25948)
The SCIM update_user (PUT) and patch_user (PATCH) handlers mapped the SCIM active field
unconditionally onto the role column (role = 'user' if active else 'pending'), so a routine
IdP sync or a misconfigured IdP that marked a locally-provisioned admin inactive would
silently strip that admin's role and could lock an instance out of its own administration.

Gate both active->role assignments on user.role != 'admin' so SCIM provisioning can
activate/deactivate ordinary users but never demotes an existing admin; admin role changes
continue to go through the dedicated admin endpoints. SCIM already cannot promote to admin
(active only maps to user/pending), so this is symmetric.

Credit to @HOHK0923 for surfacing the admin-demotion footgun.

Co-authored-by: HOHK0923 <118590749+HOHK0923@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:59:55 +02:00
Timothy Jaeryang Baek fbcdcf146b refac 2026-06-16 23:59:24 +02:00
Jamie LinandClaude Opus 4.8 ec86ce5cf7 fix(oauth): use Protected Resource Metadata scopes in MCP DCR flow (#25958)
* fix(oauth): use Protected Resource Metadata scopes in MCP DCR flow

The Dynamic Client Registration flow seeded the registration request
`scope` from the Authorization Server's `scopes_supported` (RFC 8414),
which is a full catalog of every scope the AS can grant across all
resources. Per RFC 9728 and the MCP Scope Selection Strategy, the
resource-specific Protected Resource Metadata `scopes_supported` is the
correct, least-privilege source.

The PRM is already fetched in this function; this change prefers its
`scopes_supported` and keeps the AS `scopes_supported` only as a
fallback when the PRM advertises none. Mirrors the static-credentials
fix in #24690.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(oauth): trim DCR scope comment per review feedback

Shortens the inline comment per review feedback on #25958.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:53:38 +02:00
087878ce84 Match WEB_FETCH_FILTER_LIST on hostnames with label boundaries, not URL suffix (CWE-693) (#25949)
is_string_allowed does endswith() matching and was called with the full URL
(retrieval/web/utils.py) against WEB_FETCH_FILTER_LIST, so a blocklisted host with any
path (https://blocked.example/x) ended with /x, not the host, and slipped through; the
allowlist direction false-rejected legitimate URLs and admitted attacker URLs ending in
an allowed string. The same endswith caused label confusion at the hostname call site
(retrieval/web/main.py): corp.com matched evilcorp.com, 10.0.0.1 matched 110.0.0.1.

Add is_host_allowed(host, ...) matching on DNS label boundaries (host == pattern or
host.endswith('.' + pattern)), called with the parsed hostname at both web-fetch call
sites. is_string_allowed is left unchanged for the unrelated function-name filters
(utils/middleware.py, utils/tools.py).

The separate is_global guard (validate_url / _ssrf_safe_new_conn, active when
ENABLE_RAG_LOCAL_WEB_FETCH is off) already blocks RFC1918/loopback/link-local, so this
restores the admin's intended blocking of specific public hosts.

Co-authored-by: addcontent <59762500+addcontent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:53:08 +02:00
Timothy Jaeryang Baek 4f69c33de0 refac 2026-06-16 23:52:33 +02:00
Timothy Jaeryang Baek c205d3a353 refac 2026-06-16 23:48:01 +02:00
Timothy Jaeryang Baek 4210cae68e refac 2026-06-16 23:41:17 +02:00
de8ea08f5c Route user-supplied image-URL fetches through an SSRF-safe session (DNS rebinding, CWE-918) (#25960)
The connection-layer DNS-rebinding guard (_SSRFSafeResolver / _SSRFSafeAdapter, PR #24759) was
mounted only on SafeWebBaseLoader. Two user-reachable image fetches validate the URL then fetch
it through the shared get_session() pool with the default resolver, so a TTL-0 rebinding answer
that passed validate_url reaches an internal address at connect:

- get_image_base64_from_url (utils/files.py): user image_url on every chat completion.
- load_url_image (routers/images.py, POST /api/v1/images/edit): user-supplied image field.

Add get_ssrf_safe_session() (a one-off aiohttp session mounting _SSRFSafeResolver) and use it
for both fetches, so the connect-time IP is re-validated and a rebound loopback / RFC1918 /
metadata address is rejected. The shared pool is left untouched for the admin-configured
image-generation callers, which legitimately reach internal hosts.

Co-authored-by: dhyabi2 <32069256+dhyabi2@users.noreply.github.com>
Co-authored-by: geo-chen <2404584+geo-chen@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:36:53 +02:00
Algorithm5838 d07e4154fe fix: prevent model edit loss from access auto-save (#26004) 2026-06-16 23:36:36 +02:00
Timothy Jaeryang Baek bb1419328b refac 2026-06-16 23:30:24 +02:00
Timothy Jaeryang Baek 40c09167cd refac 2026-06-16 23:24:27 +02:00
Timothy Jaeryang Baek 56ae99e96a refac 2026-06-16 23:02:20 +02:00
G30 1eecbc1ac2 fix(ui): collapse Code Execution settings when toggle is disabled (#25970) 2026-06-16 23:01:22 +02:00
Timothy Jaeryang Baek 78a5015846 refac 2026-06-16 23:00:31 +02:00
G30 b93d560788 feat(ui): pre-select last model when adding a new model slot (#25974) 2026-06-16 22:56:56 +02:00
b7626f05fb Authorize KB write access before auto-linking an uploaded file (CWE-862/863) (#26001)
process_uploaded_file auto-links an uploaded file to the knowledge base named in
client-supplied metadata.knowledge_id, but it called Knowledges.add_file_to_knowledge_by_id
directly with no authorization, while the dedicated POST /knowledge/{id}/file/add endpoint
gates the same operation on owner / admin / write grant. A read-only collaborator (or any
verified user who knows the KB id) could therefore attach arbitrary files to a knowledge base
they cannot write: the KnowledgeFile membership row was committed, and the later vector-index
write check failed closed but left the row in place.

Gate the auto-link on the same owner/admin/write check before inserting the membership row;
otherwise skip the link and log. The file still uploads as the user's own file, it just is
not linked to a KB they cannot write.

Co-authored-by: jagstack <52110932+jagstack@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:56:24 +02:00
Martin Gronek 1fc0e3ade7 fix: correct German date format token for dayjs in de-DE translation (#25985)
TT and JJJJ are German placeholders but not valid dayjs format tokens.
Replaced with DD and YYYY which dayjs understands correctly.
2026-06-16 22:55:23 +02:00
G30 84e4110538 fix(auth): enforce chat.tts permission on OpenAI /audio/speech proxy endpoint (#25993) 2026-06-16 22:54:35 +02:00
Timothy Jaeryang Baek 19a176fd36 refac 2026-06-16 22:53:57 +02:00
G30 bb66d435b7 fix(auth): enforce features.api_keys permission on GET and DELETE /api_key endpoints (#25992) 2026-06-16 22:48:44 +02:00
dc4924b66e Enforce per-model access on arena fallback before bypass_filter dispatch (#26046)
generate_chat_completion() checks model access on line 200 only when bypass_filter is
False. When an arena model reaches this function without a pre-resolved selected_model_id,
which is the task and background path (the /api/v1/tasks/* endpoints call
generate_chat_completion directly rather than through process_chat_payload), the fallback
resolves the arena to an underlying model and recurses with bypass_filter=True, so the
resolved model's access check is skipped. An authenticated user with access to an arena
could therefore reach a model they are denied directly, and for the default or exclude
arena, whose candidate pool is every non-arena model, any model on the instance (CWE-862).
The normal chat path resolves the arena in process_chat_payload before this function, so its
resolved model is checked on line 200; the task path was not, which is the inconsistency.

Enforce check_model_access() on the resolved model in the fallback, before the
bypass_filter=True recursion, mirroring the normal-path check. Admins and already-bypassed
recursive calls are unaffected, and legitimate arena use of accessible models is unchanged.

Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:45:42 +02:00
920b655f46 Gate scheduled automations and fail closed on per-model access for non-user roles (#26047)
Two lifecycle/authorization gaps let a deactivated (pending) account keep acting:

1. The background automation scheduler (execute_automation) rehydrated the owner by ID and
   dispatched the chat pipeline without re-checking the owner. A user later set to pending,
   or one whose features.automations permission was revoked, kept running scheduled
   automations on the operator's provider credentials, even though the HTTP create/update/run
   routes already gate on get_verified_user + features.automations. Re-gate the rehydrated
   owner before dispatch: require role user/admin and, for non-admins, the features.automations
   permission; otherwise record an error and skip the run.

2. check_model_access enforced per-model ACLs only for exactly role == 'user', so any other
   non-admin role (a pending principal) fell through and was granted access. Enforce for every
   non-admin role (admins still bypass), so the check fails closed (CWE-862, CWE-863).

Co-authored-by: rexpository <30176934+rexpository@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:45:27 +02:00
05098d25a5 Fail closed when the proxy/redirect path decode cap is exceeded (#26050)
The decode-until-stable loops in _sanitize_proxy_path (terminals.py, cap 8) and
_safe_static_redirect_path (models.py, cap 2) proceed with whatever remains after the
cap instead of rejecting it. A path encoded more times than the cap therefore exits the
loop still percent-encoded, passes the literal '..' / prefix checks (the dots are still
%2E, not '..'), and is forwarded to the upstream terminal server, or emitted as a
redirect Location, which then decodes it once more and resolves the traversal. This is
the residual of the decode-until-stable hardening added for CVE-2026-54017: the cap is a
fixed depth, not a true stability guarantee.

Reject when the value is still not stable after the cap (unquote(x) != x), so anything
encoded more deeply than the cap fails closed rather than being forwarded. Legitimate
paths stabilize within a pass or two and are unaffected.

Co-authored-by: DavidCarliez <271374756+DavidCarliez@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:44:11 +02:00
Timothy Jaeryang Baek 3266a8c9eb refac 2026-06-16 22:43:40 +02:00
Andrew Baek ecdb6f353a Merge pull request #26060 from andrewbbaek/dev
feat: add auth-login-card ID for custom CSS targeting
2026-06-16 22:35:25 +02:00
Timothy Jaeryang Baek 084d040e22 refac 2026-06-16 00:05:14 +02:00
Timothy Jaeryang Baek 76854d1424 refac 2026-06-15 23:53:52 +02:00
Timothy Jaeryang Baek 45fcf272ef refac 2026-06-15 23:42:29 +02:00
Timothy Jaeryang Baek c783fd30f2 refac 2026-06-15 23:37:38 +02:00
Timothy Jaeryang Baek d65ac445a4 refac 2026-06-15 23:34:24 +02:00
Timothy Jaeryang Baek 38920c0ed1 refac 2026-06-15 23:32:06 +02:00
Timothy Jaeryang Baek 5019af79a0 refac 2026-06-15 23:31:59 +02:00
Timothy Jaeryang Baek f85cb27ef8 refac 2026-06-13 02:13:51 +01:00
Timothy Jaeryang Baek 4602abe5c6 refac 2026-06-13 02:13:39 +01:00
Timothy Jaeryang Baek b1d40f3409 refac 2026-06-05 16:58:16 -04:00
Tim Baek 02dc3e689c Merge pull request #25590 from open-webui/dev
refac
2026-06-01 19:10:46 -07:00
Timothy Jaeryang Baek 1a97751e37 refac 2026-06-01 19:09:44 -07:00
Tim Baek b868da6bcd Merge pull request #25589 from open-webui/dev
refac
2026-06-01 19:05:05 -07:00
Timothy Jaeryang Baek dc4dab7a07 refac 2026-06-01 19:01:14 -07:00
Tim Baek 90f4b4fcda Merge pull request #25524 from open-webui/dev
refac
2026-06-01 15:01:27 -07:00
Timothy Jaeryang Baek 4be25c4fdd refac 2026-06-01 15:01:01 -07:00
+36 b3e9d16b6f 0.9.6 (#25497)
* refac

* refac

* refac: reorganize scripts and ci workflows

* chore: remove unused cypress tests

* chore: remove unused legacy test suite

* refac: deprecate peewee migration layer

The Alembic init migration (7e5b5dc7342b) already creates the
equivalent schema. Peewee migrations are no longer needed for
any version >= 0.3.6.

* refac: remove dead peewee connection wrappers

* refac

* refac

* style: ruff format

* style: standardize os.environ.get to os.getenv

* refac: modernize imports, standardize type hints and docstrings

* refac: modernize type annotations (PEP 604 / PEP 585)

* refac

* feat: kb_exec

* refac

* refac

* refac

* refac

* upd:i18n: es-ES Translation update v0.9.5 (#24651)

es-ES Translation. Update v0.9.5

Added translation of new strings.

* feat: knowledge directory

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: German i18n translations (#24668)

* i18n: Update Swedish (sv-SE) translation — merge with latest dev (#24665)

Co-authored-by: Daniel Nylander <daniel@danielnylander.se>

* refac

* Fix translation for 'Authentication' in Danish (#24645)

* refac

* fix: korean i18n

* i18n: Update catalan translation.json (#24569)

* refac

* fix: validate folder_id ownership on chat create + folder-update endpoints (#24588)

POST /api/v1/chats/new and POST /api/v1/chats/{id}/folder accepted a
caller-supplied folder_id with no validation — neither ownership, nor
existence, nor UUID format. The row was persisted with the supplied
value verbatim, so the DB ended up with chat rows whose folder_id
referenced another user's folder, a non-existent UUID, or even a
non-UUID string.

No read path surfaces this across users — every chat-folder read is
user_id-filtered on both sides — so this is referential-integrity
hardening rather than a security boundary fix. But there's no reason
to accept dangling references either, and the downstream consumers
shouldn't have to assume the column is clean.

Add a Folders.get_folder_by_id_and_user_id() lookup at both writers:
if a folder_id is supplied, it must match a folder owned by the
caller. None remains allowed (chat-without-folder is the default).
Non-existent and non-UUID values fall through to 404.

Reported by ShigekiTsuchiyama in GHSA-4vrg-2vcq-q7jc.

Co-authored-by: ShigekiTsuchiyama <ShigekiTsuchiyama@users.noreply.github.com>

* refac

* refac

* refac

* refac

* fix: enforce features.direct_tool_servers on chat-completion tool_servers (#24693)

* fix: enforce features.direct_tool_servers on chat-completion tool_servers

The features.direct_tool_servers per-user permission was correctly
enforced on the storage path (routers/users.py user/settings/update,
which strips toolServers from saved settings when the caller lacks the
permission), but the inference path (/api/chat/completions) popped
tool_servers straight from the request body into metadata with no
permission check. The middleware (utils/middleware.py:2799) then
consumed direct_tool_servers to inject system_prompt into the message
array and register external tool specs that get invoked during the
completion. End result: any authenticated user could bypass the
admin-set per-user feature toggle and use inline tool_servers in their
chat-completion requests, even when admin had explicitly denied the
permission.

Default for USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS is False
(config.py:2750), so under default config no regular user is supposed
to be able to use direct tool servers — making this a real boundary
bypass on out-of-the-box deployments rather than a corner case.

Mirror the storage-side behaviour at the inference entry point: pop
tool_servers from the request body, then silently drop the value if
the caller is non-admin and lacks features.direct_tool_servers. Admins
always pass; users with the explicit grant always pass; everyone else
gets None propagated into metadata, which the middleware already
handles as the no-tool-servers case.

Reported by berkant-koc in GHSA-f582-c373-jjf6.

Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>

* chore: trim verbose comment on tool_servers permission check

---------

Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: legacy peewee tables fk

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* fix: default optional env vars used with bash `,,` in start.sh (#24683)

start.sh runs with `set -euo pipefail`, but three call sites added in
070ab2650 (refac: reorganize scripts and ci workflows) reference
optional env vars via bash's `,,` lowercase expansion without any
default. Containers that don't set these vars — the default for every
deployment that isn't explicitly opting into Playwright / bundled
Ollama / CUDA — crash on startup with:

    start.sh: line 15: WEB_LOADER_ENGINE: unbound variable

(and the same for USE_OLLAMA_DOCKER, USE_CUDA_DOCKER once the first
were set in turn.) Reported in open-webui#24560 by urbenlegend.

The same refactor correctly defaulted every other optional env var
with `${VAR:-…}`. The three `,,` references slipped through because
bash can't combine `:-default` with `,,` in a single substitution —
`${VAR:-default,,}` makes the default literal `,,`, not what's wanted.

Fix: normalise the three vars in a one-line preamble with `${VAR:=}`,
which assigns an empty default if unset. The downstream `${VAR,,}`
expressions stay exactly as Tim wrote them, preserving the file's
visual style and matching the existing `${VAR:-…}` idiom for "this
variable is optional".

* i18n: update Russian translations (#24728)

* Update SECURITY.md (#24726)

* fix: tag composite pk in migration (#24722)

* feat(ui): add emoji picker to rich text formatting toolbar (#24704)

* fix: wire workspace.skills into the sidebar + workspace-index gates (#24729)

Reported by bwgabrielsusai on #24719: granting a user only
`workspace.skills` doesn't show the Workspace menu, and visiting
`/workspace` directly bounces them to `/`.

The per-route guard in `/workspace/+layout.svelte` already covered
skills, but two earlier gates in the chain didn't:

* `Sidebar.svelte` case 'workspace' OR'd models/knowledge/prompts/tools
  to decide menu visibility — skills was missing, so the entry never
  rendered for skills-only users.
* `/workspace/+page.svelte` redirect chain picked the first available
  section — skills was missing, so the fallback `goto('/')` fired.

Adding skills to both.

* refac

* refac

* chore: pyodide

* fix: get_image_base64_from_file_id

* refac

* i18n: update Irish translation (#24883)

* refac

* refactor: remove dead frontend API wrappers with no backend route (#24792)

These 19 exported wrappers are dead: each appears exactly once in the
codebase (its own definition), nothing imports or calls any of them, and
none has a corresponding backend route. They are leftovers from settings
that were consolidated server-side into /auths/admin/config,
/openai/config, /ollama/config and /api/config:

- index.ts: getModelFilterConfig, updateModelFilterConfig,
  getCommunitySharingEnabledStatus, toggleCommunitySharingEnabledStatus,
  getModelConfig, updateModelConfig (+ orphaned GlobalModelConfig type)
- auths: getSignUpEnabledStatus, toggleSignUpEnabledStatus,
  getDefaultUserRole, updateDefaultUserRole, getJWTExpiresDuration,
  updateJWTExpiresDuration
- openai: getOpenAIUrls, updateOpenAIUrls, getOpenAIKeys, updateOpenAIKeys
- ollama: updateOllamaUrls
- prompts: restorePromptFromHistory
- folders: updateFolderItemsById (+ orphaned FolderItems type)

Shared types (ModelConfig/ModelMeta/ModelParams) and all live wrappers
are untouched. Removal is import-safe: nothing referenced these.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: remove unused GET /evaluations/feedbacks/all endpoint (#24778)

GET /evaluations/feedbacks/all returned the entire feedback table in a
single response (flagged as a Medium OOM risk for admins in
open-webui#22206). Its only frontend wrapper, getAllFeedbacks in
src/lib/apis/evaluations/index.ts, is dead: nothing imports or calls it
anywhere in the codebase. The endpoint is a redundant view-only twin of
GET /evaluations/feedbacks/all/export, which is what the admin Feedbacks
UI actually uses.

Removes the endpoint, the now-unused FeedbackResponse import in the
evaluations router, and the dead getAllFeedbacks frontend wrapper. The
shared Feedbacks.get_all_feedbacks data-layer method is kept, since the
live /feedbacks/all/export endpoint still uses it.

Ref: open-webui#22206

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: remove unused POST /api/v1/utils/markdown endpoint (#24779)

POST /utils/markdown rendered a markdown string to HTML server-side. Its
only frontend wrapper, getHTMLFromMarkdown in src/lib/apis/utils/index.ts,
is dead: nothing imports or calls it, the route is hit by no other code
path, and the path string appears nowhere else in the repo (no direct
fetch, no test, no docs). Markdown is rendered client-side in the UI, so
this endpoint was redundant.

Fully self-contained removal: the endpoint, its MarkdownForm model, the
now-orphaned 'import markdown' in the utils router (used only here), and
the dead getHTMLFromMarkdown wrapper. Nothing else depends on any of them.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* refac

* refac

Co-Authored-By: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>

* refactor: remove unused DELETE /chats/{id}/tags/all endpoint (#24785)

The bulk-clear-chat-tags endpoint's only frontend wrapper,
deleteTagsById in src/lib/apis/chats/index.ts, is dead: nothing imports
or calls it, the path is referenced nowhere else, and the route handler
has no internal caller. Removes the route handler, the dead wrapper, and
the now-orphaned Chats.delete_all_tags_by_id_and_user_id model method
(its sole caller was this route). The shared
Chats.delete_orphan_tags_for_user method is untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac: audio

* fix: pass subscription_key and endpoint in bing.py CLI search_bing() call (#24768)

The __main__ block called search_bing() with 4 positional arguments, but
the function requires 5 (subscription_key, endpoint, locale, query,
count). Running `python -m open_webui.retrieval.web.bing` raised a
TypeError and, before failing, silently misrouted every argument. Read
the key/endpoint from environment variables, matching config.py defaults.

Closes #24765

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: check destination calendar write access on event update (#24764)

update_event only verified write access on the event's source calendar.
CalendarEventUpdateForm accepts a new calendar_id which the model layer
applies unconditionally, so a user with write access to their own calendar
could move (inject) an event into any other user's calendar. Mirror the
destination check create_event already performs.

* refactor: remove dead generateFollowUps frontend wrapper (#24794)

generateFollowUps in src/lib/apis/index.ts is dead: it appears only at
its own definition, nothing imports or calls it, and it targets a
non-existent path (/api/v1/tasks/follow_ups/completions, plural) while
the real route is /tasks/follow_up/completions (singular). Follow-up
suggestions are generated server-side in the chat-completion middleware
and delivered over the chat:message:follow_ups websocket event, so this
wrapper was never on the live path.

Removes only the dead wrapper. The backend POST /tasks/follow_up/completions
endpoint is intentionally kept: it is a member of the actively-used
/tasks/*/completions family (title, tags, emoji, queries, moa) and its
handler delegates to the core generate_follow_ups function.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: validate Playwright navigations and gate redirects in web loader (#24756)

SafePlaywrightURLLoader validated only the initially submitted URL and
then let the browser follow HTTP redirects and client-side navigations
without re-checking them, so a public URL could redirect into the
internal network (cloud metadata, RFC1918, loopback). Intercept
document-type requests, re-run validate_url on each, and apply the same
redirect policy as the requests loader (blocked unless
AIOHTTP_CLIENT_ALLOW_REDIRECTS). Sub-resource requests pass through
unchanged so page rendering performance is unaffected.

Co-authored-by: POV9en <POV9en@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* refac

* enh: linkup

* fix: log expected fetch/transcript/tool-server failures as warnings (#24903)

* fix: emit [DONE] for AsyncGenerator pipe returns (#24763)

* fix: respect access_type in shared-chat file authorization branch (#24755)

has_access_to_file granted access whenever the file was attached to a
shared chat the user could read, ignoring the requested access_type. A
read-only shared-chat recipient therefore satisfied write and delete
checks and could delete or mutate the chat owner's attached file. Gate
the shared-chat branch on read access, matching the channels branch
directly above it.

Co-authored-by: oxsignal <oxsignal@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* fix: mitigate DNS rebinding in web loader fetch paths (#24759)

validate_url() resolves DNS to check IPs but discards the result; the
HTTP client resolves again independently.  Between those two lookups an
attacker can swap the DNS record from a public IP to an internal one
(DNS rebinding).

Push the IP-is-global check into the actual connection layer so the
validated resolution is the one used for the TCP connect:

- aiohttp (_fetch): _SSRFSafeResolver wraps DefaultResolver and rejects
  non-global IPs at resolve time (zero TOCTOU window).
- requests (_scrape): _SSRFSafeAdapter mounts custom urllib3 connection
  classes whose _new_conn resolves, validates, and connects to the
  validated IP in one shot (zero TOCTOU window).

Both paths respect ENABLE_RAG_LOCAL_WEB_FETCH (skip validation when on).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix: disable redirect following in OAuth picture fetch (SSRF) (#24809)

_process_picture_url validated the initial picture URL with validate_url()
but then aiohttp followed 3xx redirects without re-validating the target,
so a validate_url-passing public URL could 302 to an internal address and
the body was base64-stored in the user's profile_image_url. This is the
sixth call site of the CVE-2026-45401 redirect-bypass cohort; the other
five already pass allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS. Apply
the same.

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* Run transcode_audio_to_mp3 in a thread to avoid blocking (#24876)

This incorporates the transcoding implementation in #24145.

* refac

Co-Authored-By: Sergey Zinchenko <sergey.zinchenko.rnd@gmail.com>

* refactor: remove unused GET /prompts/command/{command} endpoint (#24782)

The lookup-prompt-by-command endpoint's only frontend wrapper,
getPromptByCommand, is dead: nothing imports or calls it, the path is
referenced nowhere else, and the route handler has no internal caller
(slash-command resolution happens client-side from the loaded prompt
list). Removes the route handler, its section header, and the dead
wrapper.

The shared Prompts.get_prompt_by_command data-layer method is kept: it
is still used by create/update prompt validation (prompts.py:175, 275,
340). PromptAccessResponse and AccessGrants are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac: kb sync

* refac

* refac

* refac

* refac

* refac

* refac: clean up Redis sentinel utilities and import grouping

* fix: resolve NameError for redis_sentinels in session_cleanup_lock

The variable was renamed to ws_sentinels but session_cleanup_lock
still referenced the old name, causing a startup crash.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Update knowledge.py (#25053)

* refac

* refac

* fix(prompts): resolve undefined session variable in _get_access_grants and _to_prompt_model (#25129)

Both _get_access_grants and _to_prompt_model referenced an undefined
local variable 'session' instead of the 'db' parameter passed to each
method. Because these helpers are called outside of any
'async with get_async_db_context()' block, 'session' did not exist in
their scope, causing a NameError on every prompt fetch.

The NameError was silently swallowed by the broad 'except Exception'
clause in get_prompt_by_id, which returned None — causing the frontend
[id]/+page.svelte to immediately redirect back to /workspace/prompts
rather than rendering the prompt editor.

Also adds the missing 'logging' import and module-level 'log' logger,
which was referenced (but never imported) in insert_new_prompt,
update_prompt_version, and delete_prompt_by_id.

* fix: add knowledge_id access check in search_knowledge_files (BOLA) (#25113)

When called without attached model knowledge and given a caller-supplied knowledge_id, search_knowledge_files passed it straight to Knowledges.search_files_by_id, which does not enforce ownership on knowledge_id. An authenticated user who happened to know a target UUID could enumerate file metadata (filename, file id, KB id, KB name, updated_at) from any knowledge base, bypassing the AccessGrants permission model.

Mirror the same admin/owner/AccessGrants check the attached-KB branch already uses, matching the sibling query_knowledge_files function.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* fix(auth): use request.scope["path"] to prevent CVE-2026-48710 (BadHost) (#25123)

Starlette reconstructs request.url.path from the HTTP Host header without
validation. An attacker can inject a path into the Host header to make
request.url.path return a different value than the path Starlette routes on.

The API key endpoint restriction check was using request.url.path to decide
whether to allow or deny access — making it bypassable via a crafted Host
header on any Starlette version prior to 1.0.1.

Fix: replace request.url.path with request.scope["path"], which reads the
raw ASGI scope path that Starlette uses for routing. This value is set by
the ASGI server from the actual request path and cannot be injected via
HTTP headers, making it safe regardless of Starlette version.

Affected code path:
  get_current_user_by_api_key() in backend/open_webui/utils/auth.py
  (only triggered when ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS is enabled)

References:
  CVE-2026-48710 / BadHost
  https://arstechnica.com/information-technology/2026/05/millions-of-ai-agents-imperiled-by-critical-vulnerability-in-open-source-package/

* I18n/improve chinese translation (#25114)

* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

* refac

* refac

* refac

* refac

* fix: harden model profile image against SVG stored XSS (#25060)

ModelMeta.profile_image_url now runs validate_profile_image_url, rejecting SVG/script data URIs (matching UserUpdateForm and ChannelWebhookForm). The /model/profile/image endpoint enforces the PROFILE_IMAGE_ALLOWED_MIME_TYPES allowlist and sets X-Content-Type-Options: nosniff, so an SVG data URI can no longer be served inline on-origin. Closes the fourth profile-image XSS sink missed by the user and webhook fixes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: gate chat-file links by caller access + repair insert_chat_files db arg (#25054)

insert_chat_files() stored any caller-supplied file_id with no ownership
check, so a user could attach another user's file to their own chat and
then read it through the shared-chat access path in has_access_to_file().
Filter file_ids to those the caller owns, is admin for, or can read.

Also repairs an UnboundLocalError introduced in 260ead64d: the existing
duplicate-check referenced `session` before it was assigned (db=session),
so the function threw on every call and no chat_file rows were persisted.

* Update fi-FI translation.json (#24963)

Added missing translations and improved existing ones.

* Update main.py (#25271)

* fix(i18n): add missing Korean plural _one keys for selected/sources/minutes (#25228)

* fix: sanitize mermaid SVG output to prevent stored XSS in file preview (#25219)

renderMermaidDiagram returned raw mermaid SVG, which FilePreview.svelte injects
via wrapper.innerHTML = svg. Mermaid runs with securityLevel: 'loose', so it
neither sanitizes click hrefs (formatUrl skips sanitizeUrl) nor DOMPurifies its
output; a .md file with a click X href "javascript:..." directive (or an
HTML-label payload) therefore executes script in the app origin when previewed.
The chat path was already safe because SVGPanZoom DOMPurifies before rendering;
file preview was not.

Sanitize at the source: renderMermaidDiagram now returns DOMPurify-cleaned SVG
via a shared sanitizeSvg helper (same policy as SVGPanZoom), so every consumer
including the FilePreview innerHTML sink receives safe output.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: preserve parent_id on chat_message upsert (#25205)

* fix(ui): include reasoning_tags in user settings advanced params save handler (#25204)

* fix(ui): prevent long usernames from overflowing Edit User modal, User Preview modal, and sidebar (#25185)

Long usernames overflow the Edit User modal, User Preview modal header,
and the sidebar user area because the flex containers lack width
constraints.

- EditUserModal: add min-w-0 to the flex-1 container so the existing
  truncate class takes effect
- UserPreviewModal: add min-w-0 and truncate to the title container,
  flex-shrink-0 to the close button so it stays visible
- Sidebar: add truncate to the username display and flex-shrink-0 to
  the avatar container to prevent it from being squeezed

* fix(ui): add voice mode mute shortcut to keyboard shortcuts modal (#25193)

* fix(types): add missing markdown rendering settings to Settings type (#25198)

* i18n: complete Turkish (tr-TR) translation (#25210)

* fix: remove hardcoded WEBUI_SECRET_KEY fallback, require key explicitly (#25218)

The 't0p-s3cr3t' default was dead code on every supported startup path:
start.sh, start_windows.bat and `open-webui serve` all set or
auto-generate WEBUI_SECRET_KEY before the backend imports env.py. It was
only ever reachable by invoking uvicorn directly, which is unsupported
and unsafe (the app would then sign tokens/cookies with a public,
hardcoded key). It also keeps getting reported as a vulnerability because
it looks dangerous, even though it is unreachable in practice.

Drop the fallback (default to '') so an unset key is caught by the
existing WEBUI_AUTH guard, and replace the vague error with a clear,
actionable message explaining that the key is a hard requirement and how
the supported start methods provide it. Exit cleanly via SystemExit
instead of raising a ValueError traceback.

WEBUI_AUTH=False keeps working unchanged (key defaults to '').

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: move bypass_system_prompt off query parameter onto request.state (#25156)

bypass_system_prompt is an internal flag used by utils/middleware.py and utils/chat.py to skip applying the model system prompt on recursive base-model calls, but it was still declared as a positional argument on the openai/ollama chat-completion route handlers, so FastAPI bound it from the query string. Move it to request.state so external clients cannot set it, matching how bypass_filter is handled.

Drop the argument from both route signatures and read getattr(request.state, 'bypass_system_prompt', False); utils/chat.py sets request.state.bypass_system_prompt alongside bypass_filter and drops the kwarg from the two route-handler calls (the recursive self-calls keep it). Mirrors c0385f60b.

Co-authored-by: anishgirianish <161533316+anishgirianish@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(pl-PL): add missing polish translations (#25176)

* fix(models): gracefully handle legacy svg profile_image_url in ModelMeta validator (#25173)

The SVG-XSS hardening introduced in f5f4b5895 correctly rejects
data:image/svg+xml URIs on new input, but also caused a
pydantic_core.ValidationError when reading pre-existing models from
the database that had SVG data URIs stored as their profile images.

This ValidationError propagated unhandled through _to_model_model and
get_all_models, crashing the entire /api/models endpoint with HTTP 500
and leaving users with no models available in the UI.

Fix:
- Wrap validate_profile_image_url() in a try/except ValueError inside
  ModelMeta.check_profile_image_url. Legacy entries are cleared to None
  with a warning log instead of raising — the /model/profile/image API
  endpoint already falls back to /static/favicon.png when the value is
  empty.
- Default ModelMeta.profile_image_url to None instead of hardcoding
  /static/favicon.png, since the serving endpoint handles the fallback.
- Add a per-model try/except in ModelsTable.get_all_models so that any
  future unexpected validation failure on a single record skips that
  model with an error log rather than aborting the entire list.

* refac

* fix: add null guards to channel Thread and PinnedMessagesModal components (#25209)

Thread.svelte: Add null check for messagesContainerElement in scrollToBottom()
to match the existing pattern in Channel.svelte. Prevents potential TypeError
when the DOM element is not yet bound during rapid thread switches.

PinnedMessagesModal.svelte: Move res.length check inside the if (res) block.
Previously, res.length was accessed unconditionally after a guarded block,
causing TypeError when the API call fails and the .catch() returns null.

* fix(settings): correct presence_penalty and repeat_penalty saving wrong values (#25183)

Both presence_penalty and repeat_penalty in the saveHandler read from
params.frequency_penalty instead of their own values due to a
copy-paste error. This causes users adjusting either parameter to
silently save the frequency_penalty value instead.

* fix(ui): enable custom parameters in user settings and admin model settings (#25200)

Add missing custom={true} prop to AdvancedParams in General.svelte and
ModelSettingsModal.svelte so the 'Add Custom Parameter' option appears
consistently across all advanced parameter surfaces.

Also forward custom_params in the General.svelte save handler so custom
parameters are persisted instead of silently dropped on save.

* i18n : (ms-MY) refine translation and standardise terminology (#25164)

* refac

* refac

* fix: decode terminal proxy path until stable to block multi-encoded traversal (#25157)

_sanitize_proxy_path decoded the proxy path once before the '..' check, so a double-encoded payload (%252e%252e) survived the check as %2e%2e and was then re-decoded into '..' by the upstream terminal server, defeating the traversal guard. Decode until stable so no encoded traversal sequence can reach the upstream. Single-encoded payloads were already rejected; this closes the double (and deeper) encoding bypass.

Co-authored-by: sermikr0 <230672901+sermikr0@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* chore: Update CHANGELOG.md (#24680)

* 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

* fix: use db instead of undefined session in chats model (#25455)

* fix(ui): use correct 'blur' event name instead of 'blur-sm' in window listeners (#25459)

* fix(ui): correct inverted high-contrast text colors for user message timestamp (#25461)

* refac

* refac

* refac

* refac

* fix: clear usage interval in finally so it cannot leak on send failure (#25478)

getChatEventEmitter starts a setInterval emitting a `usage` socket event
every second; clearInterval ran only after sendMessageSocket resolved, so
any throw/reject left the interval firing for the page lifetime. Each
failed send added another orphaned interval, inflating server-side usage
accounting and growing CPU/memory over a session.

Wrap the send in try/finally so the interval is always cleared, on both
the happy path and any thrown/rejected path. The exception still
propagates unchanged.

Fixes #25465

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: cap profile image data URI size to bound model/avatar bloat (#25476)

* feat: cap profile image data URI size to bound model/avatar bloat

validate_profile_image_url() validated data-URI format (MIME allowlist,
SVG rejection, scheme checks) but never its length, so a valid
data:image/...;base64,<huge> passed for both custom-model icons and user
avatars. Large inline images bloat Postgres and the Redis MODELS hash and
degrade model-list latency.

Add PROFILE_IMAGE_MAX_DATA_URI_SIZE (default 256 KiB, 0 disables) and
reject oversized data URIs in the shared validator, so both model meta
(ModelMeta.profile_image_url) and user avatars (UpdateProfileForm) are
bounded at one chokepoint. ModelMeta already clears invalid values to
None on read, so existing oversized icons stop propagating into the
MODELS hash on the next refresh.

Fixes #25468

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: default PROFILE_IMAGE_MAX_DATA_URI_SIZE to None (no cap)

Per review: opt-in rather than a 256 KiB default. Unset leaves data URIs
uncapped; the validator already skips the check on a falsy value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refac

* fix: don't hang terminal proxy when one forwarding pump exits first (#25479)

The proxy gathered _client_to_upstream and _upstream_to_client with
return_exceptions=True. When upstream sends a graceful CLOSE,
_upstream_to_client returns but gather keeps waiting on
_client_to_upstream, which is blocked in ws.receive() until the browser
disconnects. The handler stays pending and the finally: session.close()
cleanup is deferred, leaking a ClientSession and an open browser socket.

Use asyncio.wait(return_when=FIRST_COMPLETED) and cancel the pending
sibling, so the proxy unwinds as soon as either direction finishes. The
pumps' bare except Exception already lets CancelledError (a BaseException)
propagate, so cancellation is clean and they need no change.

Fixes #25464

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refac

Co-Authored-By: bannert <58707896+bannert1337@users.noreply.github.com>

* refac

Co-Authored-By: Boris Rybalkin <ribalkin@gmail.com>

* fix: cache path traversal via sibling-prefix bypass in serve_cache_file (#25086)

serve_cache_file gated the resolved path with file_path.startswith(os.path.abspath(CACHE_DIR)) without a trailing os.sep, so any path resolving to a sibling whose name starts with the cache-dir basename (e.g. cache_backup, cached_models) passed the prefix check. Authenticated users could read files from such siblings via /cache/../<sibling>/<file>. Appending os.sep to the prefix closes the bypass; deep traversal and absolute paths were already correctly blocked.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: persist outlet filter changes to message output (#24884)

* refac

* fix(images): pass content_type=None to r.json() to accept non-standard MIME types (#24838)

aiohttp's ClientResponse.json() validates the Content-Type header against
'application/json' by default and raises ContentTypeError for any other
value — including 'application/x-ndjson', which Ollama returns for its
OpenAI-compatible /v1/images/generations endpoint.

Pass content_type=None to skip this check while keeping all other parsing
behaviour unchanged.  The fix covers image generation (openai, gemini,
automatic1111 engines) and image editing (openai, gemini engines).

* fix(ui): guard JSON.parse(localStorage) calls with try/catch to prevent UI crashes (#25481)

* fix: don't crash on startup when stdout can't encode the banner (#25482)

The startup banner uses Unicode box-drawing characters. On a stdout that
can't encode them (Windows cp1252, or redirected/headless/pythonw output)
print() raises UnicodeEncodeError and aborts startup. This blocks running
open-webui serve headless on Windows.

Guard the banner print and fall back to a plain ASCII line so startup
always proceeds regardless of the console encoding.

Fixes #24965

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refac

* fix(knowledge): remove premature drag-and-drop upload toast (#25484)

* refac

* fix: don't block first-admin signup on stale ENABLE_SIGNUP (#24821)

Symptom
On a fresh install (zero users) the frontend shows the mandatory
"Create Admin Account" onboarding screen, but POST
/api/v1/auths/signup returns 403 ACCESS_PROHIBITED ("You do not have
permission to access this resource."). Wiping the database does not
help when the config layer is backed by Redis (the value survives in
the Redis/valkey volume), or when only the user table is cleared (the
config row survives in Postgres). The instance is then unrecoverable
through the UI.

Root cause
signup_handler() auto-sets request.app.state.config.ENABLE_SIGNUP =
False immediately after the first admin is created. That value is
persisted by the config layer (the Postgres config table, and Redis
when REDIS_URL is set). On a later zero-user database the persisted
False is read back, so ENABLE_SIGNUP resolves False even though no
users exist. The old gate was:

    if WEBUI_AUTH:
        if not ENABLE_SIGNUP or not ENABLE_LOGIN_FORM:
            if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
                403

ENABLE_INITIAL_ADMIN_SIGNUP defaults to False, so with zero users the
inner test (has_users or not ENABLE_INITIAL_ADMIN_SIGNUP) is True, and
a stale ENABLE_SIGNUP=False trips the outer test, producing a 403 on
the only UI path that can create the first admin. The frontend decides
to show onboarding purely from user_count == 0, so frontend and
backend disagree and the instance bricks.

Change
Split the gate by has_users. Subsequent signups (has_users True) are
unchanged: still gated by ENABLE_SIGNUP and ENABLE_LOGIN_FORM. The
first user (has_users False, the bootstrap admin the onboarding screen
invites) is gated only by the admin-chosen ENABLE_LOGIN_FORM (the
documented SSO-only hard-disable) unless ENABLE_INITIAL_ADMIN_SIGNUP is
set. It is no longer gated by ENABLE_SIGNUP, which in the zero-user
state is never an admin decision but the post-first-admin auto-disable
leaking across a database reset.

Why this is safe (full case analysis)
For WEBUI_AUTH the gate has 16 input combinations over (has_users,
ENABLE_SIGNUP, ENABLE_LOGIN_FORM, ENABLE_INITIAL_ADMIN_SIGNUP). Old and
new are identical in 15 of them:
  * All 8 has_users=True cases: both reduce to "403 iff not
    ENABLE_SIGNUP or not ENABLE_LOGIN_FORM". Unchanged.
  * 7 of the 8 has_users=False cases: identical.
The only changed case is has_users=False, ENABLE_SIGNUP=False,
ENABLE_LOGIN_FORM=True, ENABLE_INITIAL_ADMIN_SIGNUP=False: old
behaviour 403, new behaviour allow. The new condition is a strict
subset of the old (new-403 implies old-403), so the change never newly
blocks any request that previously succeeded; it only stops blocking
that one bootstrap state.

That state has no legitimate deployment. With the login form enabled
and zero users the onboarding form is already served, and the only
operator-configurable way to keep the first signup closed (SSO-only:
ENABLE_LOGIN_FORM=False, optionally with ENABLE_INITIAL_ADMIN_SIGNUP)
is preserved byte for byte. ENABLE_SIGNUP=False with zero users is not
an operator choice, it is the automatic post-first-admin disable, so
the old behaviour there was purely a brick with no recovery path. No
security control is weakened: ENABLE_LOGIN_FORM and
ENABLE_INITIAL_ADMIN_SIGNUP keep their exact meaning, and the
WEBUI_AUTH=False path is untouched.

This is not Redis-specific: it reproduces with Redis disabled through
the Postgres config table alone (clear the user table, keep the config
row).

Verification
Drove the real signup endpoint across a 10-case matrix on freshly
migrated databases, including the full end-to-end first-admin creation
(returns role=admin, row persisted as admin) and the preserved
SSO-only, subsequent-signup and no-auth behaviours. All pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: don't revert replace/outlet content on chat save (#25485)

update_chat_by_id re-derived assistant content from `output` on every
save (serialize_output) so frontend edits to output items reflect in
content. But it ran unconditionally, so content set independently of
output — an __event_emitter__ {"type":"replace"} from an Action, or an
outlet filter footer — was reverted to the original output-derived text
on the next save. The reload reads chat.chat directly, so the change
vanished after navigating away (regression vs 0.9.2, which predates the
output mechanism).

Re-derive only when the message's `output` actually changed versus what's
stored, which still reflects genuine output edits but leaves
independently-set content intact.

Fixes #24585

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: reject collection names with unsafe characters in RAG ACL (#24982)

Open WebUI's collection ACL accepted any unknown name as a
legacy/ephemeral collection. In Milvus multi-tenancy mode that name
becomes the `resource_id` and is interpolated unescaped into a SQL-like
Milvus expression — `resource_id == '<name>'` — so a name like
  x' or resource_id != '' or resource_id == 'x
turns the filter into a tautology and returns every tenant's chunks
from the shared collection.

All collection names Open WebUI generates are UUIDs, SHA-256 hex
digests, or fixed-prefix variants of those — they all fit
[A-Za-z0-9_-]. Add a strict format check in
filter_accessible_collections (utils.py) that drops any name outside
that set before any ACL or vector-store lookup, applied even on the
admin bypass path. _validate_collection_access then surfaces the dropped
name as a 403.

As defense in depth, MilvusClient now validates resource_id at every
expression-construction site and escapes single quotes / backslashes in
any other string interpolated into a filter (delete ids, metadata
filter values). Non-string filter values are typed-checked instead of
str()-formatted.

Co-authored-by: Claude <noreply@anthropic.com>

* refac

* refac: mirror native FC code_interpreter authz gates onto legacy XML-tag path (#24724)

The native function-calling tool resolver in utils/tools.py applies five
gates before exposing execute_code as a builtin tool: builtin-category
enable, ENABLE_CODE_INTERPRETER global config, model capability,
features.code_interpreter request flag, and the per-user
features.code_interpreter permission.

The legacy XML-tag detection path in streaming_chat_response_handler
applied only the request-flag gate. Brings the legacy path to parity by
running the same five-gate check before activating tag detection.
Behaviour change is limited to deployments that previously relied on
the asymmetry — admins who set ENABLE_CODE_INTERPRETER=False or revoked
the per-user permission, on the legacy tool-calling mode, with the
client supplying features.code_interpreter=true. Any of those three
conditions met now correctly disables tag detection.

Co-authored-by: sfwani <sfwani@users.noreply.github.com>

* fix: handle list-shape data in Firecrawl /search response (#24712)

Firecrawl /search returns either `{"data": [...]}` (flat list — v1, and
what frost19k reported on #23966) or `{"data": {"web": [...]}}` (v2,
current production). The parser only handled the dict shape:

    data = response.get('data') or {}
    results = data.get('web') or []

On a list-shape response, `data.get('web')` raised AttributeError,
caught by the function's outer try/except, and `search_firecrawl`
silently returned []. Web search worked against v2 endpoints but is one
upstream-format-change away from failing closed again. Accept either.

* fix: bind prompt history/version ops to the authorized prompt (#25056)

The history diff, delete, and version-restore routes authorize the URL
prompt_id but then act on a caller-supplied history/version id without
checking it belongs to that prompt (IDOR). Filter by prompt_id in
compute_diff and delete_history_entry, and reject a cross-prompt version_id
in update_prompt_version.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* feat: add support for Valkey vector database (#24769)

* feat: add support for Valkey vector database

Signed-off-by: Riley Des <riley.desserre@improving.com>

* feat: add CLIENT SETNAME to Valkey vector store connections

Set client_name on GlideClientConfiguration for both the main client
and batch client so connections are identifiable in CLIENT LIST,
monitoring dashboards, and CloudWatch metrics.

Signed-off-by: Riley Des <riley.desserre@improving.com>

---------

Signed-off-by: Riley Des <riley.desserre@improving.com>

* refac

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* refac

Co-Authored-By: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>

* refac

* refac

* refac

* Update Kagi API endpoint and request method (#25015)

Co-authored-by: russelg <russelg@users.noreply.github.com>

* feat: add skills management to chat component (#25037)

- Introduced skills functionality in Chat.svelte, MessageInput.svelte, and related components.
- Added SkillsModal for displaying and managing available skills.
- Updated state management to include selectedSkillIds and integrate skills API.
- Enhanced UI to show available skills and their descriptions.
- Updated translations to support skills-related text.

* refac

* refac

* refac

Co-Authored-By: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>

* refac

* refac

* refac

* refac

* refac

* fix: apply RAG_EMBEDDING_QUERY_PREFIX to memory search queries (#24921)

The query_memory endpoint embeds the search query without the configured
RAG_EMBEDDING_QUERY_PREFIX, while every RAG retrieval path in
retrieval/utils.py correctly passes it. Instruction-tuned embedding
models (e.g. Qwen3-Embedding) produce poor results without the prefix,
causing memory search to return semantically unrelated results.

* refac

* fix: gate chat_completion channel: branch on channel access + message scoping (#24725)

* fix: gate chat_completion channel: branch on channel access + message scoping

When chat_id starts with 'channel:' the chat-completion handler skips
the chat ownership / storage block below it. Nothing replaced that
gate. The downstream channel emitter in socket/main.py:_make_channel_
emitter writes to Messages.update_message_by_id using a caller-supplied
message_id pulled from form_data['id'], with no membership check, no
write-access check, and no validation that the message_id belongs to
the channel.

Net effect: any authenticated user could submit
chat_id='channel:<any-channel-uuid>' + id='<any-message-uuid>' and
overwrite that message with attacker-controlled LLM output. Cross-
channel writes worked too — private channels, DMs, channels the
caller has no access to. Original author attribution stayed intact on
the overwritten row.

Add the missing checks at the channel: branch:

1. Channel must exist (404 otherwise).
2. Non-admin caller must have write access to the channel — membership
   for group/dm channels, AccessGrants permission='write' for others.
3. The message_id (if supplied) must belong to the same channel — a
   caller with write access to channel A cannot use this path to
   overwrite a message in channel B.

Behaviour change is limited to callers who were exploiting the gap:
legitimate flows that supply a message_id under their own channel
membership continue to work unchanged.

Co-authored-by: sfwani <sfwani@users.noreply.github.com>

* chore: trim verbose comment on channel: branch gate

---------

Co-authored-by: sfwani <sfwani@users.noreply.github.com>

* refac

* feat(retrieval): add Perplexity attribution header (#24833)

Signed-off-by: James Liounis <james.liounis@perplexity.ai>

* refac

* refac

* Update CHANGELOG.md (#25453)

* 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

* fix: polyfill readable stream async iteration for Safari PDF extraction (#25473)

* refactor: move background tasks handler call to ensure consistent execution in chat response handlers (#24717)

* refac

* fix(oauth): use Protected Resource Metadata scopes in static OAuth 2.1 flow (#24690)

The static credentials OAuth flow currently sets scope=None, relying on
the OAuth provider's default scopes. This breaks providers like GitHub
that default to minimal/public-only access when no scope is requested.

This change reads scopes_supported from the Protected Resource Metadata
document (RFC 9728) and uses them in the authorization request. Unlike
the Authorization Server's scopes_supported (a full catalog of every
scope the AS can grant), the PRM scopes_supported represents what the
specific resource requires — making it safe to request without breaking
providers like Entra ID that reject broad scope requests.

Fixes the regression introduced in 349ea4ea where all scope handling was
removed from the static flow.

* refac

* chore: bump

* chore

* chore: format

* Update CHANGELOG.md (#25491)

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* refac

* refac

Co-Authored-By: Syed Mustafa Quadri <175467872+code-quad3@users.noreply.github.com>

* chore: format

* refac

Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>

* fix: delete Qdrant points by ID so memory deletions don't orphan vectors (#25495)

The Qdrant backends implemented delete(ids=...) as a payload filter on
metadata.id, but points are stored with the item id as the Qdrant point id
(see _create_points), and not every point carries an id in its payload.
Memory points store only {created_at} in metadata (KB metadata embeddings
likewise), so deleting a single memory matched nothing and left an orphaned
vector that kept being injected into RAG context.

Delete by point id instead: PointIdsList for the standard backend, and a
tenant-scoped HasIdCondition for multitenancy (point ids are unique, so tenant
isolation is preserved). Filter-based deletion is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(fr-fr): update frensh translations (#24614)

Co-authored-by: Marina Pantazis <marina.pantazis@bit.admin.ch>
Co-authored-by: Tim Baek <tim@openwebui.com>

* fix: block private-IP webhook URLs to close SSRF on caller-controlled URL (#24587)

* fix: block private-IP webhook URLs to close SSRF on caller-controlled URL

post_webhook(url, ...) in utils/webhook.py forwards the URL straight to
aiohttp.ClientSession.post with no SSRF gate. The URL is caller-controlled
on two surfaces:

- User notification settings under ENABLE_USER_WEBHOOKS=true — any
  authenticated user can set the URL their notifications POST to.
- Automation notification triggers (calendar alerts, etc.).

Without a gate, the URL can target cloud metadata (169.254.169.254 /
fd00:ec2::254), localhost-bound services, RFC1918 internal hosts, or any
other private address reachable from the server process. Blind SSRF — no
response body returned to the caller — but enough to enumerate internal
services via response timing / status codes, and on cloud deployments
enough to issue requests against IMDSv1 if available.

Call validate_url() at the top of post_webhook. The function blocks
private/reserved IPs when ENABLE_RAG_LOCAL_WEB_FETCH is False (the
default), is the project's chosen SSRF gate, and is already applied to
the equivalent fetch surfaces (retrieval, image-load, OAuth profile
picture). Operators who legitimately need to webhook to private IPs
(internal monitoring, self-hosted Slack alternatives, etc.) can set
ENABLE_RAG_LOCAL_WEB_FETCH=True — same opt-out as the other gated
surfaces.

Scope intentionally limited to webhooks. The OAuth discovery and
external reranker paths cwanglab also flagged are admin-configured with
intentional private-IP defaults (reranker defaults to
http://localhost:8080/v1/rerank) and are out of scope per Rule 9 — the
admin owns the URL choice and the operator opt-out exists for them too.

Reported by cwanglab in GHSA-5x9f-85cg-w3hf (cluster canonical with six
closed siblings: g36v-23gj-j69x, 6j8f-h58v-xgmw, xpwv-52pm-p8hj,
v9gp-hv2c-9qv8, fw7w-jrw7-p3v9, x7xq-74rg-m8mf).

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

* fix: also pass allow_redirects=False on webhook post_webhook session.post

Companion to the previous commit. validate_url() only validates the
initial URL; aiohttp's default allow_redirects=True would still follow
a 302 to a private-IP target. Same redirect-bypass class as the rh5x
cluster's five call sites, sixth call site to receive the same gate.

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

---------

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

* refac

* chore: format

* refac

* refac

* refac

---------

Signed-off-by: Riley Des <riley.desserre@improving.com>
Signed-off-by: James Liounis <james.liounis@perplexity.ai>
Co-authored-by: _00_ <131402327+rgaricano@users.noreply.github.com>
Co-authored-by: Taey <pythontogoplease@gmail.com>
Co-authored-by: Daniel Nylander <po@danielnylander.se>
Co-authored-by: Daniel Nylander <daniel@danielnylander.se>
Co-authored-by: Asbjørn Dyhrberg Thegler <asbjoern@dyhrbergthegler.dk>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: ShigekiTsuchiyama <ShigekiTsuchiyama@users.noreply.github.com>
Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>
Co-authored-by: mayamsin <58122670+mayamsin@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: G30 <50341825+silentoplayz@users.noreply.github.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: POV9en <POV9en@users.noreply.github.com>
Co-authored-by: oxsignal <oxsignal@users.noreply.github.com>
Co-authored-by: Dara Adib <dara@quietapple.org>
Co-authored-by: Sergey Zinchenko <sergey.zinchenko.rnd@gmail.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: maco <gosarmarcel7@gmail.com>
Co-authored-by: Sakıp Han Dursun <100518315+sakiphan@users.noreply.github.com>
Co-authored-by: anishgirianish <161533316+anishgirianish@users.noreply.github.com>
Co-authored-by: Mateusz Hajder <6783135+mhajder@users.noreply.github.com>
Co-authored-by: Amir Subhi <amirsubhi@hotmail.com>
Co-authored-by: sermikr0 <230672901+sermikr0@users.noreply.github.com>
Co-authored-by: bannert <58707896+bannert1337@users.noreply.github.com>
Co-authored-by: Boris Rybalkin <ribalkin@gmail.com>
Co-authored-by: HW <hwinkler@first-it-consulting.de>
Co-authored-by: sfwani <sfwani@users.noreply.github.com>
Co-authored-by: rileydes-improving <riley.desserre@improving.com>
Co-authored-by: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>
Co-authored-by: Lukáš Kucharczyk <lukas@kucharczyk.xyz>
Co-authored-by: russelg <russelg@users.noreply.github.com>
Co-authored-by: Mr. Meowgi <ovehbe@gmail.com>
Co-authored-by: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>
Co-authored-by: Craig <66838006+TipKnuckle@users.noreply.github.com>
Co-authored-by: James Liounis <james.liounis@perplexity.ai>
Co-authored-by: Chane Lu <2522992009@qq.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: Justin Williams <justinjohnwilliams@gmail.com>
Co-authored-by: Syed Mustafa Quadri <175467872+code-quad3@users.noreply.github.com>
Co-authored-by: hungryBird <pantazis.marina@gmail.com>
Co-authored-by: Marina Pantazis <marina.pantazis@bit.admin.ch>
Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>
2026-06-01 14:56:48 -07:00
Timothy Jaeryang Baek de2360ac07 refac 2026-06-01 14:38:54 -07:00
Timothy Jaeryang Baek 8862210139 refac 2026-06-01 14:38:49 -07:00
Timothy Jaeryang Baek 5062f9958f refac 2026-06-01 14:35:45 -07:00
Timothy Jaeryang Baek eb629866fe chore: format 2026-06-01 14:33:43 -07:00
Timothy Jaeryang Baek 54149903f0 refac 2026-06-01 14:23:27 -07:00
Classic298andcwanglab 02b2a391e9 fix: block private-IP webhook URLs to close SSRF on caller-controlled URL (#24587)
* fix: block private-IP webhook URLs to close SSRF on caller-controlled URL

post_webhook(url, ...) in utils/webhook.py forwards the URL straight to
aiohttp.ClientSession.post with no SSRF gate. The URL is caller-controlled
on two surfaces:

- User notification settings under ENABLE_USER_WEBHOOKS=true — any
  authenticated user can set the URL their notifications POST to.
- Automation notification triggers (calendar alerts, etc.).

Without a gate, the URL can target cloud metadata (169.254.169.254 /
fd00:ec2::254), localhost-bound services, RFC1918 internal hosts, or any
other private address reachable from the server process. Blind SSRF — no
response body returned to the caller — but enough to enumerate internal
services via response timing / status codes, and on cloud deployments
enough to issue requests against IMDSv1 if available.

Call validate_url() at the top of post_webhook. The function blocks
private/reserved IPs when ENABLE_RAG_LOCAL_WEB_FETCH is False (the
default), is the project's chosen SSRF gate, and is already applied to
the equivalent fetch surfaces (retrieval, image-load, OAuth profile
picture). Operators who legitimately need to webhook to private IPs
(internal monitoring, self-hosted Slack alternatives, etc.) can set
ENABLE_RAG_LOCAL_WEB_FETCH=True — same opt-out as the other gated
surfaces.

Scope intentionally limited to webhooks. The OAuth discovery and
external reranker paths cwanglab also flagged are admin-configured with
intentional private-IP defaults (reranker defaults to
http://localhost:8080/v1/rerank) and are out of scope per Rule 9 — the
admin owns the URL choice and the operator opt-out exists for them too.

Reported by cwanglab in GHSA-5x9f-85cg-w3hf (cluster canonical with six
closed siblings: g36v-23gj-j69x, 6j8f-h58v-xgmw, xpwv-52pm-p8hj,
v9gp-hv2c-9qv8, fw7w-jrw7-p3v9, x7xq-74rg-m8mf).

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

* fix: also pass allow_redirects=False on webhook post_webhook session.post

Companion to the previous commit. validate_url() only validates the
initial URL; aiohttp's default allow_redirects=True would still follow
a 302 to a private-IP target. Same redirect-bypass class as the rh5x
cluster's five call sites, sixth call site to receive the same gate.

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

---------

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>
2026-06-01 14:15:51 -07:00
c73d9da773 i18n(fr-fr): update frensh translations (#24614)
Co-authored-by: Marina Pantazis <marina.pantazis@bit.admin.ch>
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-06-01 14:15:38 -07:00
Classic298andClaude Opus 4.8 eb38389636 fix: delete Qdrant points by ID so memory deletions don't orphan vectors (#25495)
The Qdrant backends implemented delete(ids=...) as a payload filter on
metadata.id, but points are stored with the item id as the Qdrant point id
(see _create_points), and not every point carries an id in its payload.
Memory points store only {created_at} in metadata (KB metadata embeddings
likewise), so deleting a single memory matched nothing and left an orphaned
vector that kept being injected into RAG context.

Delete by point id instead: PointIdsList for the standard backend, and a
tenant-scoped HasIdCondition for multitenancy (point ids are unique, so tenant
isolation is preserved). Filter-based deletion is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:13:46 -07:00
Timothy Jaeryang BaekandJacob Leksan eebbc48f80 refac
Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
2026-06-01 14:13:28 -07:00
Timothy Jaeryang Baek cff51f05f5 chore: format 2026-06-01 14:10:40 -07:00
Timothy Jaeryang BaekandSyed Mustafa Quadri a4735e46b9 refac
Co-Authored-By: Syed Mustafa Quadri <175467872+code-quad3@users.noreply.github.com>
2026-06-01 14:09:54 -07:00
Timothy Jaeryang Baek 6c8dfd8175 refac 2026-06-01 14:08:34 -07:00
Classic298 caaa99c7d3 Update CHANGELOG.md (#25491)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2026-06-01 14:07:20 -07:00
Timothy Jaeryang Baek 6fce92aa12 chore: format 2026-06-01 13:56:55 -07:00
Timothy Jaeryang Baek 1f49ebf431 chore 2026-06-01 13:56:06 -07:00
Timothy Jaeryang Baek bfe5523c52 chore: bump 2026-06-01 13:54:10 -07:00
Timothy Jaeryang Baek 77c8c54b1e refac 2026-06-01 13:53:19 -07:00
Justin Williams 478bc9e3f1 fix(oauth): use Protected Resource Metadata scopes in static OAuth 2.1 flow (#24690)
The static credentials OAuth flow currently sets scope=None, relying on
the OAuth provider's default scopes. This breaks providers like GitHub
that default to minimal/public-only access when no scope is requested.

This change reads scopes_supported from the Protected Resource Metadata
document (RFC 9728) and uses them in the authorization request. Unlike
the Authorization Server's scopes_supported (a full catalog of every
scope the AS can grant), the PRM scopes_supported represents what the
specific resource requires — making it safe to request without breaking
providers like Entra ID that reject broad scope requests.

Fixes the regression introduced in 349ea4ea where all scope handling was
removed from the static flow.
2026-06-01 13:52:18 -07:00
Timothy Jaeryang Baek 675e9bee5a refac 2026-06-01 13:51:03 -07:00
Jacob Leksan 80da840ae5 refactor: move background tasks handler call to ensure consistent execution in chat response handlers (#24717) 2026-06-01 13:50:15 -07:00
Chane Lu 974b13839e fix: polyfill readable stream async iteration for Safari PDF extraction (#25473) 2026-06-01 13:47:57 -07:00
Classic298 886c132a10 Update CHANGELOG.md (#25453)
* 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-06-01 13:46:17 -07:00
Timothy Jaeryang Baek c7de057a4a refac 2026-06-01 13:45:23 -07:00
Timothy Jaeryang Baek 750604a11d refac 2026-06-01 13:43:05 -07:00
James Liounis 69c88e163d feat(retrieval): add Perplexity attribution header (#24833)
Signed-off-by: James Liounis <james.liounis@perplexity.ai>
2026-06-01 13:40:52 -07:00
Timothy Jaeryang Baek c8eb8edca4 refac 2026-06-01 13:38:40 -07:00
Classic298andsfwani 33e4e0dcc4 fix: gate chat_completion channel: branch on channel access + message scoping (#24725)
* fix: gate chat_completion channel: branch on channel access + message scoping

When chat_id starts with 'channel:' the chat-completion handler skips
the chat ownership / storage block below it. Nothing replaced that
gate. The downstream channel emitter in socket/main.py:_make_channel_
emitter writes to Messages.update_message_by_id using a caller-supplied
message_id pulled from form_data['id'], with no membership check, no
write-access check, and no validation that the message_id belongs to
the channel.

Net effect: any authenticated user could submit
chat_id='channel:<any-channel-uuid>' + id='<any-message-uuid>' and
overwrite that message with attacker-controlled LLM output. Cross-
channel writes worked too — private channels, DMs, channels the
caller has no access to. Original author attribution stayed intact on
the overwritten row.

Add the missing checks at the channel: branch:

1. Channel must exist (404 otherwise).
2. Non-admin caller must have write access to the channel — membership
   for group/dm channels, AccessGrants permission='write' for others.
3. The message_id (if supplied) must belong to the same channel — a
   caller with write access to channel A cannot use this path to
   overwrite a message in channel B.

Behaviour change is limited to callers who were exploiting the gap:
legitimate flows that supply a message_id under their own channel
membership continue to work unchanged.

Co-authored-by: sfwani <sfwani@users.noreply.github.com>

* chore: trim verbose comment on channel: branch gate

---------

Co-authored-by: sfwani <sfwani@users.noreply.github.com>
2026-06-01 13:37:32 -07:00
Timothy Jaeryang Baek 7f7cd21018 refac 2026-06-01 13:34:50 -07:00
Craig ce4dca47cb fix: apply RAG_EMBEDDING_QUERY_PREFIX to memory search queries (#24921)
The query_memory endpoint embeds the search query without the configured
RAG_EMBEDDING_QUERY_PREFIX, while every RAG retrieval path in
retrieval/utils.py correctly passes it. Instruction-tuned embedding
models (e.g. Qwen3-Embedding) produce poor results without the prefix,
causing memory search to return semantically unrelated results.
2026-06-01 13:23:15 -07:00
Timothy Jaeryang Baek 160a6694e4 refac 2026-06-01 13:20:33 -07:00
Timothy Jaeryang Baek 4705c2d988 refac 2026-06-01 13:20:26 -07:00
Timothy Jaeryang Baek 778dba1d6b refac 2026-06-01 13:18:44 -07:00
Timothy Jaeryang Baek 27fb20c13a refac 2026-06-01 13:15:21 -07:00
Timothy Jaeryang Baek eb4eebc3ce refac 2026-06-01 13:10:19 -07:00
Timothy Jaeryang BaekandZaid Marji d64ef1803d refac
Co-Authored-By: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>
2026-06-01 13:07:49 -07:00
Timothy Jaeryang Baek 346dab3d8f refac 2026-06-01 13:03:13 -07:00
Timothy Jaeryang Baek 01810e32ad refac 2026-06-01 13:02:48 -07:00
Mr. Meowgi a9da054572 feat: add skills management to chat component (#25037)
- Introduced skills functionality in Chat.svelte, MessageInput.svelte, and related components.
- Added SkillsModal for displaying and managing available skills.
- Updated state management to include selectedSkillIds and integrate skills API.
- Enhanced UI to show available skills and their descriptions.
- Updated translations to support skills-related text.
2026-06-01 12:53:30 -07:00
Lukáš Kucharczykandrusselg 42c2393f8e Update Kagi API endpoint and request method (#25015)
Co-authored-by: russelg <russelg@users.noreply.github.com>
2026-06-01 12:48:44 -07:00
Timothy Jaeryang Baek e90a618f45 refac 2026-06-01 12:47:59 -07:00
Timothy Jaeryang Baek 4297c02b12 refac 2026-06-01 12:44:16 -07:00
Timothy Jaeryang Baek 9035601bdb refac 2026-06-01 12:41:30 -07:00
Timothy Jaeryang BaekandZixin Yu e3ab4bd212 refac
Co-Authored-By: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>
2026-06-01 12:37:34 -07:00
Timothy Jaeryang BaekandClassic298 fd76b51ab2 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-06-01 12:27:08 -07:00
Timothy Jaeryang Baek c0f1aa2919 refac 2026-06-01 12:24:45 -07:00
rileydes-improving 567c4aabe9 feat: add support for Valkey vector database (#24769)
* feat: add support for Valkey vector database

Signed-off-by: Riley Des <riley.desserre@improving.com>

* feat: add CLIENT SETNAME to Valkey vector store connections

Set client_name on GlideClientConfiguration for both the main client
and batch client so connections are identifiable in CLIENT LIST,
monitoring dashboards, and CloudWatch metrics.

Signed-off-by: Riley Des <riley.desserre@improving.com>

---------

Signed-off-by: Riley Des <riley.desserre@improving.com>
2026-06-01 12:20:01 -07:00
Timothy Jaeryang BaekandClassic298 8644532f5b refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-06-01 12:13:04 -07:00
Classic298andClaude Opus 4.7 9a3eea6448 fix: bind prompt history/version ops to the authorized prompt (#25056)
The history diff, delete, and version-restore routes authorize the URL
prompt_id but then act on a caller-supplied history/version id without
checking it belongs to that prompt (IDOR). Filter by prompt_id in
compute_diff and delete_history_entry, and reject a cross-prompt version_id
in update_prompt_version.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 12:07:52 -07:00
Classic298 e623081b2b fix: handle list-shape data in Firecrawl /search response (#24712)
Firecrawl /search returns either `{"data": [...]}` (flat list — v1, and
what frost19k reported on #23966) or `{"data": {"web": [...]}}` (v2,
current production). The parser only handled the dict shape:

    data = response.get('data') or {}
    results = data.get('web') or []

On a list-shape response, `data.get('web')` raised AttributeError,
caught by the function's outer try/except, and `search_firecrawl`
silently returned []. Web search worked against v2 endpoints but is one
upstream-format-change away from failing closed again. Accept either.
2026-06-01 12:07:31 -07:00
Classic298andsfwani 507b8b213c refac: mirror native FC code_interpreter authz gates onto legacy XML-tag path (#24724)
The native function-calling tool resolver in utils/tools.py applies five
gates before exposing execute_code as a builtin tool: builtin-category
enable, ENABLE_CODE_INTERPRETER global config, model capability,
features.code_interpreter request flag, and the per-user
features.code_interpreter permission.

The legacy XML-tag detection path in streaming_chat_response_handler
applied only the request-flag gate. Brings the legacy path to parity by
running the same five-gate check before activating tag detection.
Behaviour change is limited to deployments that previously relied on
the asymmetry — admins who set ENABLE_CODE_INTERPRETER=False or revoked
the per-user permission, on the legacy tool-calling mode, with the
client supplying features.code_interpreter=true. Any of those three
conditions met now correctly disables tag detection.

Co-authored-by: sfwani <sfwani@users.noreply.github.com>
2026-06-01 12:07:15 -07:00
Timothy Jaeryang Baek c93f071700 refac 2026-06-01 11:58:16 -07:00
Classic298andClaude 76947ff926 fix: reject collection names with unsafe characters in RAG ACL (#24982)
Open WebUI's collection ACL accepted any unknown name as a
legacy/ephemeral collection. In Milvus multi-tenancy mode that name
becomes the `resource_id` and is interpolated unescaped into a SQL-like
Milvus expression — `resource_id == '<name>'` — so a name like
  x' or resource_id != '' or resource_id == 'x
turns the filter into a tautology and returns every tenant's chunks
from the shared collection.

All collection names Open WebUI generates are UUIDs, SHA-256 hex
digests, or fixed-prefix variants of those — they all fit
[A-Za-z0-9_-]. Add a strict format check in
filter_accessible_collections (utils.py) that drops any name outside
that set before any ACL or vector-store lookup, applied even on the
admin bypass path. _validate_collection_access then surfaces the dropped
name as a 403.

As defense in depth, MilvusClient now validates resource_id at every
expression-construction site and escapes single quotes / backslashes in
any other string interpolated into a filter (delete ids, metadata
filter values). Non-string filter values are typed-checked instead of
str()-formatted.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-01 11:48:43 -07:00
Classic298andClaude Opus 4.8 a089842368 fix: don't revert replace/outlet content on chat save (#25485)
update_chat_by_id re-derived assistant content from `output` on every
save (serialize_output) so frontend edits to output items reflect in
content. But it ran unconditionally, so content set independently of
output — an __event_emitter__ {"type":"replace"} from an Action, or an
outlet filter footer — was reverted to the original output-derived text
on the next save. The reload reads chat.chat directly, so the change
vanished after navigating away (regression vs 0.9.2, which predates the
output mechanism).

Re-derive only when the message's `output` actually changed versus what's
stored, which still reflects genuine output edits but leaves
independently-set content intact.

Fixes #24585

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:45:05 -07:00
Classic298andClaude Opus 4.7 558ea2a152 fix: don't block first-admin signup on stale ENABLE_SIGNUP (#24821)
Symptom
On a fresh install (zero users) the frontend shows the mandatory
"Create Admin Account" onboarding screen, but POST
/api/v1/auths/signup returns 403 ACCESS_PROHIBITED ("You do not have
permission to access this resource."). Wiping the database does not
help when the config layer is backed by Redis (the value survives in
the Redis/valkey volume), or when only the user table is cleared (the
config row survives in Postgres). The instance is then unrecoverable
through the UI.

Root cause
signup_handler() auto-sets request.app.state.config.ENABLE_SIGNUP =
False immediately after the first admin is created. That value is
persisted by the config layer (the Postgres config table, and Redis
when REDIS_URL is set). On a later zero-user database the persisted
False is read back, so ENABLE_SIGNUP resolves False even though no
users exist. The old gate was:

    if WEBUI_AUTH:
        if not ENABLE_SIGNUP or not ENABLE_LOGIN_FORM:
            if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
                403

ENABLE_INITIAL_ADMIN_SIGNUP defaults to False, so with zero users the
inner test (has_users or not ENABLE_INITIAL_ADMIN_SIGNUP) is True, and
a stale ENABLE_SIGNUP=False trips the outer test, producing a 403 on
the only UI path that can create the first admin. The frontend decides
to show onboarding purely from user_count == 0, so frontend and
backend disagree and the instance bricks.

Change
Split the gate by has_users. Subsequent signups (has_users True) are
unchanged: still gated by ENABLE_SIGNUP and ENABLE_LOGIN_FORM. The
first user (has_users False, the bootstrap admin the onboarding screen
invites) is gated only by the admin-chosen ENABLE_LOGIN_FORM (the
documented SSO-only hard-disable) unless ENABLE_INITIAL_ADMIN_SIGNUP is
set. It is no longer gated by ENABLE_SIGNUP, which in the zero-user
state is never an admin decision but the post-first-admin auto-disable
leaking across a database reset.

Why this is safe (full case analysis)
For WEBUI_AUTH the gate has 16 input combinations over (has_users,
ENABLE_SIGNUP, ENABLE_LOGIN_FORM, ENABLE_INITIAL_ADMIN_SIGNUP). Old and
new are identical in 15 of them:
  * All 8 has_users=True cases: both reduce to "403 iff not
    ENABLE_SIGNUP or not ENABLE_LOGIN_FORM". Unchanged.
  * 7 of the 8 has_users=False cases: identical.
The only changed case is has_users=False, ENABLE_SIGNUP=False,
ENABLE_LOGIN_FORM=True, ENABLE_INITIAL_ADMIN_SIGNUP=False: old
behaviour 403, new behaviour allow. The new condition is a strict
subset of the old (new-403 implies old-403), so the change never newly
blocks any request that previously succeeded; it only stops blocking
that one bootstrap state.

That state has no legitimate deployment. With the login form enabled
and zero users the onboarding form is already served, and the only
operator-configurable way to keep the first signup closed (SSO-only:
ENABLE_LOGIN_FORM=False, optionally with ENABLE_INITIAL_ADMIN_SIGNUP)
is preserved byte for byte. ENABLE_SIGNUP=False with zero users is not
an operator choice, it is the automatic post-first-admin disable, so
the old behaviour there was purely a brick with no recovery path. No
security control is weakened: ENABLE_LOGIN_FORM and
ENABLE_INITIAL_ADMIN_SIGNUP keep their exact meaning, and the
WEBUI_AUTH=False path is untouched.

This is not Redis-specific: it reproduces with Redis disabled through
the Postgres config table alone (clear the user table, keep the config
row).

Verification
Drove the real signup endpoint across a 10-case matrix on freshly
migrated databases, including the full end-to-end first-admin creation
(returns role=admin, row persisted as admin) and the preserved
SSO-only, subsequent-signup and no-auth behaviours. All pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 11:44:36 -07:00
Timothy Jaeryang Baek ad9f2eeb15 refac 2026-06-01 11:34:46 -07:00
G30 1010fd3142 fix(knowledge): remove premature drag-and-drop upload toast (#25484) 2026-06-01 11:09:58 -07:00
Timothy Jaeryang Baek 1bbb2b933d refac 2026-06-01 11:08:58 -07:00
Classic298andClaude Opus 4.8 cc15a01778 fix: don't crash on startup when stdout can't encode the banner (#25482)
The startup banner uses Unicode box-drawing characters. On a stdout that
can't encode them (Windows cp1252, or redirected/headless/pythonw output)
print() raises UnicodeEncodeError and aborts startup. This blocks running
open-webui serve headless on Windows.

Guard the banner print and fall back to a plain ASCII line so startup
always proceeds regardless of the console encoding.

Fixes #24965

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:41:43 -07:00
G30 ceb1cbc009 fix(ui): guard JSON.parse(localStorage) calls with try/catch to prevent UI crashes (#25481) 2026-06-01 10:33:54 -07:00
HW bddadafa07 fix(images): pass content_type=None to r.json() to accept non-standard MIME types (#24838)
aiohttp's ClientResponse.json() validates the Content-Type header against
'application/json' by default and raises ContentTypeError for any other
value — including 'application/x-ndjson', which Ollama returns for its
OpenAI-compatible /v1/images/generations endpoint.

Pass content_type=None to skip this check while keeping all other parsing
behaviour unchanged.  The fix covers image generation (openai, gemini,
automatic1111 engines) and image editing (openai, gemini engines).
2026-06-01 10:33:24 -07:00
Timothy Jaeryang Baek 936d5f2676 refac 2026-06-01 10:26:39 -07:00
Algorithm5838 309caa82fb fix: persist outlet filter changes to message output (#24884) 2026-06-01 10:24:40 -07:00
Classic298andClaude Opus 4.7 b0fa4384ea fix: cache path traversal via sibling-prefix bypass in serve_cache_file (#25086)
serve_cache_file gated the resolved path with file_path.startswith(os.path.abspath(CACHE_DIR)) without a trailing os.sep, so any path resolving to a sibling whose name starts with the cache-dir basename (e.g. cache_backup, cached_models) passed the prefix check. Authenticated users could read files from such siblings via /cache/../<sibling>/<file>. Appending os.sep to the prefix closes the bypass; deep traversal and absolute paths were already correctly blocked.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 10:17:02 -07:00
Timothy Jaeryang BaekandBoris Rybalkin 07cbc91a8e refac
Co-Authored-By: Boris Rybalkin <ribalkin@gmail.com>
2026-06-01 10:16:01 -07:00
Timothy Jaeryang Baekandbannert 0e73f7af09 refac
Co-Authored-By: bannert <58707896+bannert1337@users.noreply.github.com>
2026-06-01 10:13:50 -07:00
Classic298andClaude Opus 4.8 02b65ea582 fix: don't hang terminal proxy when one forwarding pump exits first (#25479)
The proxy gathered _client_to_upstream and _upstream_to_client with
return_exceptions=True. When upstream sends a graceful CLOSE,
_upstream_to_client returns but gather keeps waiting on
_client_to_upstream, which is blocked in ws.receive() until the browser
disconnects. The handler stays pending and the finally: session.close()
cleanup is deferred, leaking a ClientSession and an open browser socket.

Use asyncio.wait(return_when=FIRST_COMPLETED) and cancel the pending
sibling, so the proxy unwinds as soon as either direction finishes. The
pumps' bare except Exception already lets CancelledError (a BaseException)
propagate, so cancellation is clean and they need no change.

Fixes #25464

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:10:04 -07:00
Timothy Jaeryang Baek 7594823edd refac 2026-06-01 10:09:40 -07:00
Classic298andClaude Opus 4.8 83890f18b9 feat: cap profile image data URI size to bound model/avatar bloat (#25476)
* feat: cap profile image data URI size to bound model/avatar bloat

validate_profile_image_url() validated data-URI format (MIME allowlist,
SVG rejection, scheme checks) but never its length, so a valid
data:image/...;base64,<huge> passed for both custom-model icons and user
avatars. Large inline images bloat Postgres and the Redis MODELS hash and
degrade model-list latency.

Add PROFILE_IMAGE_MAX_DATA_URI_SIZE (default 256 KiB, 0 disables) and
reject oversized data URIs in the shared validator, so both model meta
(ModelMeta.profile_image_url) and user avatars (UpdateProfileForm) are
bounded at one chokepoint. ModelMeta already clears invalid values to
None on read, so existing oversized icons stop propagating into the
MODELS hash on the next refresh.

Fixes #25468

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: default PROFILE_IMAGE_MAX_DATA_URI_SIZE to None (no cap)

Per review: opt-in rather than a 256 KiB default. Unset leaves data URIs
uncapped; the validator already skips the check on a falsy value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:57:06 -07:00
Classic298andClaude Opus 4.8 e2502ec80f fix: clear usage interval in finally so it cannot leak on send failure (#25478)
getChatEventEmitter starts a setInterval emitting a `usage` socket event
every second; clearInterval ran only after sendMessageSocket resolved, so
any throw/reject left the interval firing for the page lifetime. Each
failed send added another orphaned interval, inflating server-side usage
accounting and growing CPU/memory over a session.

Wrap the send in try/finally so the interval is always cleared, on both
the happy path and any thrown/rejected path. The exception still
propagates unchanged.

Fixes #25465

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:56:49 -07:00
Timothy Jaeryang Baek 6f0277db52 refac 2026-06-01 09:53:04 -07:00
Timothy Jaeryang Baek 55ca719bbf refac 2026-06-01 09:48:29 -07:00
Timothy Jaeryang Baek 9e3e24e304 refac 2026-06-01 09:42:54 -07:00
Timothy Jaeryang Baek b64fd988f0 refac 2026-06-01 09:30:15 -07:00
G30 229e65b9f0 fix(ui): correct inverted high-contrast text colors for user message timestamp (#25461) 2026-06-01 09:26:18 -07:00
G30 bbd3e13094 fix(ui): use correct 'blur' event name instead of 'blur-sm' in window listeners (#25459) 2026-06-01 09:25:55 -07:00
Algorithm5838 16e7827134 fix: use db instead of undefined session in chats model (#25455) 2026-06-01 09:25:35 -07:00
Classic298 5028ba18ee chore: Update CHANGELOG.md (#24680)
* 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-05-31 18:42:49 -07:00
Timothy Jaeryang Baek 7139797be0 refac 2026-05-31 18:42:28 -07:00
Timothy Jaeryang Baek ee47c9c833 refac 2026-05-31 18:34:37 -07:00
0354775917 fix: decode terminal proxy path until stable to block multi-encoded traversal (#25157)
_sanitize_proxy_path decoded the proxy path once before the '..' check, so a double-encoded payload (%252e%252e) survived the check as %2e%2e and was then re-decoded into '..' by the upstream terminal server, defeating the traversal guard. Decode until stable so no encoded traversal sequence can reach the upstream. Single-encoded payloads were already rejected; this closes the double (and deeper) encoding bypass.

Co-authored-by: sermikr0 <230672901+sermikr0@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 15:11:19 -07:00
Timothy Jaeryang Baek d4030a8aa5 refac 2026-05-31 15:10:48 -07:00
Timothy Jaeryang Baek 1f0948bcbe refac 2026-05-31 15:06:22 -07:00
Amir Subhi 746979d012 i18n : (ms-MY) refine translation and standardise terminology (#25164) 2026-05-31 15:05:01 -07:00
G30 18b1a041dd fix(ui): enable custom parameters in user settings and admin model settings (#25200)
Add missing custom={true} prop to AdvancedParams in General.svelte and
ModelSettingsModal.svelte so the 'Add Custom Parameter' option appears
consistently across all advanced parameter surfaces.

Also forward custom_params in the General.svelte save handler so custom
parameters are persisted instead of silently dropped on save.
2026-05-31 15:02:32 -07:00
G30 1608a04fa9 fix(settings): correct presence_penalty and repeat_penalty saving wrong values (#25183)
Both presence_penalty and repeat_penalty in the saveHandler read from
params.frequency_penalty instead of their own values due to a
copy-paste error. This causes users adjusting either parameter to
silently save the frequency_penalty value instead.
2026-05-31 15:02:07 -07:00
G30 5112e0b62c fix: add null guards to channel Thread and PinnedMessagesModal components (#25209)
Thread.svelte: Add null check for messagesContainerElement in scrollToBottom()
to match the existing pattern in Channel.svelte. Prevents potential TypeError
when the DOM element is not yet bound during rapid thread switches.

PinnedMessagesModal.svelte: Move res.length check inside the if (res) block.
Previously, res.length was accessed unconditionally after a guarded block,
causing TypeError when the API call fails and the .catch() returns null.
2026-05-31 15:01:17 -07:00
Timothy Jaeryang Baek f16b5c4460 refac 2026-05-31 14:59:28 -07:00
G30 34b8844ccf fix(models): gracefully handle legacy svg profile_image_url in ModelMeta validator (#25173)
The SVG-XSS hardening introduced in f5f4b5895 correctly rejects
data:image/svg+xml URIs on new input, but also caused a
pydantic_core.ValidationError when reading pre-existing models from
the database that had SVG data URIs stored as their profile images.

This ValidationError propagated unhandled through _to_model_model and
get_all_models, crashing the entire /api/models endpoint with HTTP 500
and leaving users with no models available in the UI.

Fix:
- Wrap validate_profile_image_url() in a try/except ValueError inside
  ModelMeta.check_profile_image_url. Legacy entries are cleared to None
  with a warning log instead of raising — the /model/profile/image API
  endpoint already falls back to /static/favicon.png when the value is
  empty.
- Default ModelMeta.profile_image_url to None instead of hardcoding
  /static/favicon.png, since the serving endpoint handles the fallback.
- Add a per-model try/except in ModelsTable.get_all_models so that any
  future unexpected validation failure on a single record skips that
  model with an error log rather than aborting the entire list.
2026-05-31 14:55:07 -07:00
Mateusz Hajder b52d142c8d i18n(pl-PL): add missing polish translations (#25176) 2026-05-31 14:54:23 -07:00
4719881105 fix: move bypass_system_prompt off query parameter onto request.state (#25156)
bypass_system_prompt is an internal flag used by utils/middleware.py and utils/chat.py to skip applying the model system prompt on recursive base-model calls, but it was still declared as a positional argument on the openai/ollama chat-completion route handlers, so FastAPI bound it from the query string. Move it to request.state so external clients cannot set it, matching how bypass_filter is handled.

Drop the argument from both route signatures and read getattr(request.state, 'bypass_system_prompt', False); utils/chat.py sets request.state.bypass_system_prompt alongside bypass_filter and drops the kwarg from the two route-handler calls (the recursive self-calls keep it). Mirrors c0385f60b.

Co-authored-by: anishgirianish <161533316+anishgirianish@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:53:53 -07:00
Classic298andClaude Opus 4.8 f2650353da fix: remove hardcoded WEBUI_SECRET_KEY fallback, require key explicitly (#25218)
The 't0p-s3cr3t' default was dead code on every supported startup path:
start.sh, start_windows.bat and `open-webui serve` all set or
auto-generate WEBUI_SECRET_KEY before the backend imports env.py. It was
only ever reachable by invoking uvicorn directly, which is unsupported
and unsafe (the app would then sign tokens/cookies with a public,
hardcoded key). It also keeps getting reported as a vulnerability because
it looks dangerous, even though it is unreachable in practice.

Drop the fallback (default to '') so an unset key is caught by the
existing WEBUI_AUTH guard, and replace the vague error with a clear,
actionable message explaining that the key is a hard requirement and how
the supported start methods provide it. Exit cleanly via SystemExit
instead of raising a ValueError traceback.

WEBUI_AUTH=False keeps working unchanged (key defaults to '').

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:53:25 -07:00
Sakıp Han Dursun 76b0e65818 i18n: complete Turkish (tr-TR) translation (#25210) 2026-05-31 14:52:51 -07:00
G30 9c9e4f1288 fix(types): add missing markdown rendering settings to Settings type (#25198) 2026-05-31 14:52:35 -07:00
G30 26b1529a45 fix(ui): add voice mode mute shortcut to keyboard shortcuts modal (#25193) 2026-05-31 14:52:20 -07:00
G30 c665d4a7c6 fix(ui): prevent long usernames from overflowing Edit User modal, User Preview modal, and sidebar (#25185)
Long usernames overflow the Edit User modal, User Preview modal header,
and the sidebar user area because the flex containers lack width
constraints.

- EditUserModal: add min-w-0 to the flex-1 container so the existing
  truncate class takes effect
- UserPreviewModal: add min-w-0 and truncate to the title container,
  flex-shrink-0 to the close button so it stays visible
- Sidebar: add truncate to the username display and flex-shrink-0 to
  the avatar container to prevent it from being squeezed
2026-05-31 14:51:56 -07:00
G30 c428ad0c1a fix(ui): include reasoning_tags in user settings advanced params save handler (#25204) 2026-05-31 14:50:41 -07:00
Algorithm5838 4b33d7ebc1 fix: preserve parent_id on chat_message upsert (#25205) 2026-05-31 14:50:05 -07:00
Classic298andClaude Opus 4.8 bf6325ff33 fix: sanitize mermaid SVG output to prevent stored XSS in file preview (#25219)
renderMermaidDiagram returned raw mermaid SVG, which FilePreview.svelte injects
via wrapper.innerHTML = svg. Mermaid runs with securityLevel: 'loose', so it
neither sanitizes click hrefs (formatUrl skips sanitizeUrl) nor DOMPurifies its
output; a .md file with a click X href "javascript:..." directive (or an
HTML-label payload) therefore executes script in the app origin when previewed.
The chat path was already safe because SVGPanZoom DOMPurifies before rendering;
file preview was not.

Sanitize at the source: renderMermaidDiagram now returns DOMPurify-cleaned SVG
via a shared sanitizeSvg helper (same policy as SVGPanZoom), so every consumer
including the FilePreview innerHTML sink receives safe output.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:49:43 -07:00
maco 690d6e5eb1 fix(i18n): add missing Korean plural _one keys for selected/sources/minutes (#25228) 2026-05-31 14:49:19 -07:00
Classic298 81d4ed79ae Update main.py (#25271) 2026-05-31 14:48:51 -07:00
Kylapaallikko 4923920bf1 Update fi-FI translation.json (#24963)
Added missing translations and improved existing ones.
2026-05-28 17:48:06 -05:00
Classic298 3f1c52e018 fix: gate chat-file links by caller access + repair insert_chat_files db arg (#25054)
insert_chat_files() stored any caller-supplied file_id with no ownership
check, so a user could attach another user's file to their own chat and
then read it through the shared-chat access path in has_access_to_file().
Filter file_ids to those the caller owns, is admin for, or can read.

Also repairs an UnboundLocalError introduced in 260ead64d: the existing
duplicate-check referenced `session` before it was assigned (db=session),
so the function threw on every call and no chat_file rows were persisted.
2026-05-28 17:42:17 -05:00
Classic298andClaude Opus 4.7 f5f4b58958 fix: harden model profile image against SVG stored XSS (#25060)
ModelMeta.profile_image_url now runs validate_profile_image_url, rejecting SVG/script data URIs (matching UserUpdateForm and ChannelWebhookForm). The /model/profile/image endpoint enforces the PROFILE_IMAGE_ALLOWED_MIME_TYPES allowlist and sets X-Content-Type-Options: nosniff, so an SVG data URI can no longer be served inline on-origin. Closes the fourth profile-image XSS sink missed by the user and webhook fixes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:41:55 -05:00
Timothy Jaeryang Baek 78b1637a03 refac 2026-05-28 17:29:01 -05:00
Timothy Jaeryang Baek 84659035f0 refac 2026-05-28 17:28:14 -05:00
Timothy Jaeryang Baek 91810f1c4e refac 2026-05-28 17:26:31 -05:00
Timothy Jaeryang Baek 591e0aafa1 refac 2026-05-28 17:24:33 -05:00
Shirasawa e61bcc8500 I18n/improve chinese translation (#25114)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-05-28 16:44:55 -05:00
G30 66126f3861 fix(auth): use request.scope["path"] to prevent CVE-2026-48710 (BadHost) (#25123)
Starlette reconstructs request.url.path from the HTTP Host header without
validation. An attacker can inject a path into the Host header to make
request.url.path return a different value than the path Starlette routes on.

The API key endpoint restriction check was using request.url.path to decide
whether to allow or deny access — making it bypassable via a crafted Host
header on any Starlette version prior to 1.0.1.

Fix: replace request.url.path with request.scope["path"], which reads the
raw ASGI scope path that Starlette uses for routing. This value is set by
the ASGI server from the actual request path and cannot be injected via
HTTP headers, making it safe regardless of Starlette version.

Affected code path:
  get_current_user_by_api_key() in backend/open_webui/utils/auth.py
  (only triggered when ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS is enabled)

References:
  CVE-2026-48710 / BadHost
  https://arstechnica.com/information-technology/2026/05/millions-of-ai-agents-imperiled-by-critical-vulnerability-in-open-source-package/
2026-05-28 16:41:56 -05:00
Timothy Jaeryang Baek 79bf3d28d8 refac 2026-05-28 16:33:48 -05:00
Classic298andClaude Opus 4.7 9f20687680 fix: add knowledge_id access check in search_knowledge_files (BOLA) (#25113)
When called without attached model knowledge and given a caller-supplied knowledge_id, search_knowledge_files passed it straight to Knowledges.search_files_by_id, which does not enforce ownership on knowledge_id. An authenticated user who happened to know a target UUID could enumerate file metadata (filename, file id, KB id, KB name, updated_at) from any knowledge base, bypassing the AccessGrants permission model.

Mirror the same admin/owner/AccessGrants check the attached-KB branch already uses, matching the sibling query_knowledge_files function.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:23:49 -05:00
G30 48ea043f82 fix(prompts): resolve undefined session variable in _get_access_grants and _to_prompt_model (#25129)
Both _get_access_grants and _to_prompt_model referenced an undefined
local variable 'session' instead of the 'db' parameter passed to each
method. Because these helpers are called outside of any
'async with get_async_db_context()' block, 'session' did not exist in
their scope, causing a NameError on every prompt fetch.

The NameError was silently swallowed by the broad 'except Exception'
clause in get_prompt_by_id, which returned None — causing the frontend
[id]/+page.svelte to immediately redirect back to /workspace/prompts
rather than rendering the prompt editor.

Also adds the missing 'logging' import and module-level 'log' logger,
which was referenced (but never imported) in insert_new_prompt,
update_prompt_version, and delete_prompt_by_id.
2026-05-28 16:23:25 -05:00
Timothy Jaeryang Baek a4d1b3e937 refac 2026-05-28 16:19:35 -05:00
Timothy Jaeryang Baek 5d9a09a88a refac 2026-05-25 20:15:03 +04:00
Classic298 9a347b0376 Update knowledge.py (#25053) 2026-05-25 20:13:11 +04:00
Timothy Jaeryang Baek 42783881e9 refac 2026-05-21 18:43:45 +04:00
Timothy Jaeryang Baek fb16e28d28 refac 2026-05-21 17:48:28 +04:00
Timothy Jaeryang Baek 470a074cd1 refac 2026-05-21 16:56:56 +04:00
Timothy Jaeryang Baek b94245d2ee refac 2026-05-21 16:44:36 +04:00
Timothy Jaeryang Baek 1acfbb6755 refac 2026-05-21 16:25:25 +04:00
Timothy Jaeryang Baek d8b5b9fa79 refac 2026-05-21 15:29:49 +04:00
Timothy Jaeryang Baek 260ead64da refac 2026-05-21 14:01:57 +04:00
Timothy Jaeryang Baek cac4c6da2e fix: resolve NameError for redis_sentinels in session_cleanup_lock
The variable was renamed to ws_sentinels but session_cleanup_lock
still referenced the old name, causing a startup crash.
2026-05-21 13:41:21 +04:00
Timothy Jaeryang Baek 154679200f refac: clean up Redis sentinel utilities and import grouping 2026-05-21 11:47:25 +04:00
Timothy Jaeryang Baek 1527eb6e01 refac 2026-05-20 01:46:59 +04:00
Timothy Jaeryang Baek 8f2d346e10 refac 2026-05-20 01:39:22 +04:00
Timothy Jaeryang Baek 97252fa609 refac 2026-05-20 01:34:07 +04:00
Timothy Jaeryang Baek 9835b3f1dd refac 2026-05-20 01:32:25 +04:00
Timothy Jaeryang Baek 73bdf86766 refac 2026-05-20 01:30:26 +04:00
Timothy Jaeryang Baek 60c9db1cb8 refac: kb sync 2026-05-20 01:13:52 +04:00
Timothy Jaeryang Baek d0b17f0569 refac 2026-05-20 00:37:04 +04:00
Classic298andClaude Opus 4.7 b9911c8bc6 refactor: remove unused GET /prompts/command/{command} endpoint (#24782)
The lookup-prompt-by-command endpoint's only frontend wrapper,
getPromptByCommand, is dead: nothing imports or calls it, the path is
referenced nowhere else, and the route handler has no internal caller
(slash-command resolution happens client-side from the loaded prompt
list). Removes the route handler, its section header, and the dead
wrapper.

The shared Prompts.get_prompt_by_command data-layer method is kept: it
is still used by create/update prompt validation (prompts.py:175, 275,
340). PromptAccessResponse and AccessGrants are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:28:07 +04:00
Timothy Jaeryang BaekandSergey Zinchenko c8f851bd2d refac
Co-Authored-By: Sergey Zinchenko <sergey.zinchenko.rnd@gmail.com>
2026-05-20 00:26:22 +04:00
Dara Adib 8a104a7ab1 Run transcode_audio_to_mp3 in a thread to avoid blocking (#24876)
This incorporates the transcoding implementation in #24145.
2026-05-20 00:25:34 +04:00
Timothy Jaeryang BaekandClassic298 2b99945d27 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-05-20 00:22:27 +04:00
Classic298 d07fd7d6d8 fix: disable redirect following in OAuth picture fetch (SSRF) (#24809)
_process_picture_url validated the initial picture URL with validate_url()
but then aiohttp followed 3xx redirects without re-validating the target,
so a validate_url-passing public URL could 302 to an internal address and
the body was base64-stored in the user's profile_image_url. This is the
sixth call site of the CVE-2026-45401 redirect-bypass cohort; the other
five already pass allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS. Apply
the same.
2026-05-19 23:57:38 +04:00
Classic298andClaude Opus 4.6 854440f703 fix: mitigate DNS rebinding in web loader fetch paths (#24759)
validate_url() resolves DNS to check IPs but discards the result; the
HTTP client resolves again independently.  Between those two lookups an
attacker can swap the DNS record from a public IP to an internal one
(DNS rebinding).

Push the IP-is-global check into the actual connection layer so the
validated resolution is the one used for the TCP connect:

- aiohttp (_fetch): _SSRFSafeResolver wraps DefaultResolver and rejects
  non-global IPs at resolve time (zero TOCTOU window).
- requests (_scrape): _SSRFSafeAdapter mounts custom urllib3 connection
  classes whose _new_conn resolves, validates, and connects to the
  validated IP in one shot (zero TOCTOU window).

Both paths respect ENABLE_RAG_LOCAL_WEB_FETCH (skip validation when on).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 23:57:12 +04:00
Timothy Jaeryang Baek cfa6908d57 refac 2026-05-19 22:25:39 +04:00
Timothy Jaeryang Baek eb3076c1b0 refac 2026-05-19 22:14:46 +04:00
d169f086da fix: respect access_type in shared-chat file authorization branch (#24755)
has_access_to_file granted access whenever the file was attached to a
shared chat the user could read, ignoring the requested access_type. A
read-only shared-chat recipient therefore satisfied write and delete
checks and could delete or mutate the chat owner's attached file. Gate
the shared-chat branch on read access, matching the channels branch
directly above it.

Co-authored-by: oxsignal <oxsignal@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:09:56 +04:00
Algorithm5838 5d104abd08 fix: emit [DONE] for AsyncGenerator pipe returns (#24763) 2026-05-19 22:07:56 +04:00
Classic298 a803372805 fix: log expected fetch/transcript/tool-server failures as warnings (#24903) 2026-05-19 21:55:40 +04:00
Timothy Jaeryang Baek 56c0d00e13 enh: linkup 2026-05-19 21:54:38 +04:00
Timothy Jaeryang Baek 3c5e7968f0 refac 2026-05-19 21:48:44 +04:00
Timothy Jaeryang Baek 94b66b1797 refac 2026-05-19 21:35:12 +04:00
Timothy Jaeryang Baek ed73ef3d8d refac 2026-05-19 21:35:04 +04:00
f02aeea0bb fix: validate Playwright navigations and gate redirects in web loader (#24756)
SafePlaywrightURLLoader validated only the initially submitted URL and
then let the browser follow HTTP redirects and client-side navigations
without re-checking them, so a public URL could redirect into the
internal network (cloud metadata, RFC1918, loopback). Intercept
document-type requests, re-run validate_url on each, and apply the same
redirect policy as the requests loader (blocked unless
AIOHTTP_CLIENT_ALLOW_REDIRECTS). Sub-resource requests pass through
unchanged so page rendering performance is unaffected.

Co-authored-by: POV9en <POV9en@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:27:43 +04:00
Classic298andClaude Opus 4.7 5401e8560b refactor: remove dead generateFollowUps frontend wrapper (#24794)
generateFollowUps in src/lib/apis/index.ts is dead: it appears only at
its own definition, nothing imports or calls it, and it targets a
non-existent path (/api/v1/tasks/follow_ups/completions, plural) while
the real route is /tasks/follow_up/completions (singular). Follow-up
suggestions are generated server-side in the chat-completion middleware
and delivered over the chat:message:follow_ups websocket event, so this
wrapper was never on the live path.

Removes only the dead wrapper. The backend POST /tasks/follow_up/completions
endpoint is intentionally kept: it is a member of the actively-used
/tasks/*/completions family (title, tags, emoji, queries, moa) and its
handler delegates to the core generate_follow_ups function.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:27:19 +04:00
Classic298 ea058841c9 fix: check destination calendar write access on event update (#24764)
update_event only verified write access on the event's source calendar.
CalendarEventUpdateForm accepts a new calendar_id which the model layer
applies unconditionally, so a user with write access to their own calendar
could move (inject) an event into any other user's calendar. Mirror the
destination check create_event already performs.
2026-05-19 21:26:58 +04:00
Classic298andClaude Opus 4.7 c48ac5163c fix: pass subscription_key and endpoint in bing.py CLI search_bing() call (#24768)
The __main__ block called search_bing() with 4 positional arguments, but
the function requires 5 (subscription_key, endpoint, locale, query,
count). Running `python -m open_webui.retrieval.web.bing` raised a
TypeError and, before failing, silently misrouted every argument. Read
the key/endpoint from environment variables, matching config.py defaults.

Closes #24765

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:26:10 +04:00
Timothy Jaeryang Baek 2ca91ceeec refac: audio 2026-05-19 21:24:58 +04:00
Classic298andClaude Opus 4.7 c306a7e16e refactor: remove unused DELETE /chats/{id}/tags/all endpoint (#24785)
The bulk-clear-chat-tags endpoint's only frontend wrapper,
deleteTagsById in src/lib/apis/chats/index.ts, is dead: nothing imports
or calls it, the path is referenced nowhere else, and the route handler
has no internal caller. Removes the route handler, the dead wrapper, and
the now-orphaned Chats.delete_all_tags_by_id_and_user_id model method
(its sole caller was this route). The shared
Chats.delete_orphan_tags_for_user method is untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:03:58 +04:00
Timothy Jaeryang BaekandAlgorithm5838 cc94a90b4d refac
Co-Authored-By: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
2026-05-19 21:03:23 +04:00
Timothy Jaeryang Baek 1fa3050f06 refac 2026-05-19 20:57:56 +04:00
Timothy Jaeryang Baek 1e36a20600 refac 2026-05-19 20:55:04 +04:00
Timothy Jaeryang Baek 0adc090dcb refac 2026-05-19 20:51:53 +04:00
Classic298andClaude Opus 4.7 f216bfab63 refactor: remove unused POST /api/v1/utils/markdown endpoint (#24779)
POST /utils/markdown rendered a markdown string to HTML server-side. Its
only frontend wrapper, getHTMLFromMarkdown in src/lib/apis/utils/index.ts,
is dead: nothing imports or calls it, the route is hit by no other code
path, and the path string appears nowhere else in the repo (no direct
fetch, no test, no docs). Markdown is rendered client-side in the UI, so
this endpoint was redundant.

Fully self-contained removal: the endpoint, its MarkdownForm model, the
now-orphaned 'import markdown' in the utils router (used only here), and
the dead getHTMLFromMarkdown wrapper. Nothing else depends on any of them.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:50:07 +04:00
Classic298andClaude Opus 4.7 92e727b1fc refactor: remove unused GET /evaluations/feedbacks/all endpoint (#24778)
GET /evaluations/feedbacks/all returned the entire feedback table in a
single response (flagged as a Medium OOM risk for admins in
open-webui#22206). Its only frontend wrapper, getAllFeedbacks in
src/lib/apis/evaluations/index.ts, is dead: nothing imports or calls it
anywhere in the codebase. The endpoint is a redundant view-only twin of
GET /evaluations/feedbacks/all/export, which is what the admin Feedbacks
UI actually uses.

Removes the endpoint, the now-unused FeedbackResponse import in the
evaluations router, and the dead getAllFeedbacks frontend wrapper. The
shared Feedbacks.get_all_feedbacks data-layer method is kept, since the
live /feedbacks/all/export endpoint still uses it.

Ref: open-webui#22206

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:49:22 +04:00
Classic298andClaude Opus 4.7 557df60c3b refactor: remove dead frontend API wrappers with no backend route (#24792)
These 19 exported wrappers are dead: each appears exactly once in the
codebase (its own definition), nothing imports or calls any of them, and
none has a corresponding backend route. They are leftovers from settings
that were consolidated server-side into /auths/admin/config,
/openai/config, /ollama/config and /api/config:

- index.ts: getModelFilterConfig, updateModelFilterConfig,
  getCommunitySharingEnabledStatus, toggleCommunitySharingEnabledStatus,
  getModelConfig, updateModelConfig (+ orphaned GlobalModelConfig type)
- auths: getSignUpEnabledStatus, toggleSignUpEnabledStatus,
  getDefaultUserRole, updateDefaultUserRole, getJWTExpiresDuration,
  updateJWTExpiresDuration
- openai: getOpenAIUrls, updateOpenAIUrls, getOpenAIKeys, updateOpenAIKeys
- ollama: updateOllamaUrls
- prompts: restorePromptFromHistory
- folders: updateFolderItemsById (+ orphaned FolderItems type)

Shared types (ModelConfig/ModelMeta/ModelParams) and all live wrappers
are untouched. Removal is import-safe: nothing referenced these.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:48:02 +04:00
Timothy Jaeryang Baek 7e9d41d664 refac 2026-05-19 20:44:38 +04:00
Aindriú Mac Giolla Eoin 4f89617192 i18n: update Irish translation (#24883) 2026-05-19 20:41:19 +04:00
Timothy Jaeryang Baek aa06200f78 refac 2026-05-19 20:37:53 +04:00
Timothy Jaeryang Baek c75fe8e74b fix: get_image_base64_from_file_id 2026-05-19 20:33:46 +04:00
Timothy Jaeryang Baek 0367ad54c5 chore: pyodide 2026-05-19 20:21:01 +04:00
Timothy Jaeryang Baek ef975649b2 refac 2026-05-19 20:19:23 +04:00
Timothy Jaeryang Baek d310a0777c refac 2026-05-19 20:12:00 +04:00
Classic298 359590ca9d fix: wire workspace.skills into the sidebar + workspace-index gates (#24729)
Reported by bwgabrielsusai on #24719: granting a user only
`workspace.skills` doesn't show the Workspace menu, and visiting
`/workspace` directly bounces them to `/`.

The per-route guard in `/workspace/+layout.svelte` already covered
skills, but two earlier gates in the chain didn't:

* `Sidebar.svelte` case 'workspace' OR'd models/knowledge/prompts/tools
  to decide menu visibility — skills was missing, so the entry never
  rendered for skills-only users.
* `/workspace/+page.svelte` redirect chain picked the first available
  section — skills was missing, so the fallback `goto('/')` fired.

Adding skills to both.
2026-05-15 09:34:23 +09:00
G30 eba1540a95 feat(ui): add emoji picker to rich text formatting toolbar (#24704) 2026-05-15 09:32:56 +09:00
Algorithm5838 ae48838b04 fix: tag composite pk in migration (#24722) 2026-05-15 09:31:27 +09:00
Classic298 6360af36d8 Update SECURITY.md (#24726) 2026-05-15 09:30:48 +09:00
mayamsin 1ded5ce969 i18n: update Russian translations (#24728) 2026-05-15 09:30:10 +09:00
Classic298 0cd1529008 fix: default optional env vars used with bash ,, in start.sh (#24683)
start.sh runs with `set -euo pipefail`, but three call sites added in
070ab2650 (refac: reorganize scripts and ci workflows) reference
optional env vars via bash's `,,` lowercase expansion without any
default. Containers that don't set these vars — the default for every
deployment that isn't explicitly opting into Playwright / bundled
Ollama / CUDA — crash on startup with:

    start.sh: line 15: WEB_LOADER_ENGINE: unbound variable

(and the same for USE_OLLAMA_DOCKER, USE_CUDA_DOCKER once the first
were set in turn.) Reported in open-webui#24560 by urbenlegend.

The same refactor correctly defaulted every other optional env var
with `${VAR:-…}`. The three `,,` references slipped through because
bash can't combine `:-default` with `,,` in a single substitution —
`${VAR:-default,,}` makes the default literal `,,`, not what's wanted.

Fix: normalise the three vars in a one-line preamble with `${VAR:=}`,
which assigns an empty default if unset. The downstream `${VAR,,}`
expressions stay exactly as Tim wrote them, preserving the file's
visual style and matching the existing `${VAR:-…}` idiom for "this
variable is optional".
2026-05-15 09:29:25 +09:00
Timothy Jaeryang BaekandClassic298 9263b7568e refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-05-14 14:12:42 +09:00
Timothy Jaeryang Baek 73d2065227 fix: legacy peewee tables fk 2026-05-14 14:06:46 +09:00
Timothy Jaeryang Baek d7cfc1e46a refac 2026-05-14 14:06:32 +09:00
Timothy Jaeryang Baek 9717ada92f refac 2026-05-14 14:05:28 +09:00
Timothy Jaeryang Baek 9a8969ca93 refac 2026-05-14 13:49:50 +09:00
Timothy Jaeryang Baek 2e1b671e8d refac 2026-05-14 13:49:15 +09:00
Timothy Jaeryang Baek db2b3d7fd8 refac 2026-05-14 13:46:54 +09:00
Timothy Jaeryang Baek 1004dad274 refac 2026-05-14 13:46:36 +09:00
Timothy Jaeryang Baek ee3b14233a refac 2026-05-14 13:46:23 +09:00
Timothy Jaeryang Baek dc0f8ae6f2 refac 2026-05-14 13:46:08 +09:00
Timothy Jaeryang Baek 1b9d22e324 refac 2026-05-14 13:45:59 +09:00
Timothy Jaeryang Baek 98d3b23085 refac 2026-05-14 13:45:39 +09:00
Timothy Jaeryang Baek 6b1df94bf9 refac 2026-05-14 13:45:31 +09:00
Timothy Jaeryang Baek 95840e307a refac 2026-05-14 13:45:21 +09:00
Timothy Jaeryang Baek 6df09a4039 refac 2026-05-14 13:45:12 +09:00
Timothy Jaeryang Baek 459b1c3fda refac 2026-05-14 13:45:05 +09:00
Timothy Jaeryang Baek bd9f82d5a6 refac 2026-05-14 13:44:50 +09:00
Timothy Jaeryang Baek e5c8f8110a refac 2026-05-14 13:19:00 +09:00
Timothy Jaeryang Baek 9c14740ffb refac 2026-05-14 13:12:59 +09:00
Timothy Jaeryang Baek 74ae177d79 refac 2026-05-14 13:10:37 +09:00
Timothy Jaeryang Baek 1ea54c3217 refac 2026-05-14 13:10:22 +09:00
Timothy Jaeryang Baek f0e88dadc8 refac 2026-05-14 13:08:53 +09:00
Classic298andberkant-koc 5cc1eb5170 fix: enforce features.direct_tool_servers on chat-completion tool_servers (#24693)
* fix: enforce features.direct_tool_servers on chat-completion tool_servers

The features.direct_tool_servers per-user permission was correctly
enforced on the storage path (routers/users.py user/settings/update,
which strips toolServers from saved settings when the caller lacks the
permission), but the inference path (/api/chat/completions) popped
tool_servers straight from the request body into metadata with no
permission check. The middleware (utils/middleware.py:2799) then
consumed direct_tool_servers to inject system_prompt into the message
array and register external tool specs that get invoked during the
completion. End result: any authenticated user could bypass the
admin-set per-user feature toggle and use inline tool_servers in their
chat-completion requests, even when admin had explicitly denied the
permission.

Default for USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS is False
(config.py:2750), so under default config no regular user is supposed
to be able to use direct tool servers — making this a real boundary
bypass on out-of-the-box deployments rather than a corner case.

Mirror the storage-side behaviour at the inference entry point: pop
tool_servers from the request body, then silently drop the value if
the caller is non-admin and lacks features.direct_tool_servers. Admins
always pass; users with the explicit grant always pass; everyone else
gets None propagated into metadata, which the middleware already
handles as the no-tool-servers case.

Reported by berkant-koc in GHSA-f582-c373-jjf6.

Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>

* chore: trim verbose comment on tool_servers permission check

---------

Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>
2026-05-14 13:08:33 +09:00
Timothy Jaeryang Baek ae06e199d5 refac 2026-05-14 03:25:54 +09:00
Timothy Jaeryang Baek cc16e06c32 refac 2026-05-14 03:10:48 +09:00
Timothy Jaeryang Baek 81f611fb73 refac 2026-05-14 03:06:37 +09:00
Timothy Jaeryang Baek 245e0ee029 refac 2026-05-14 03:01:48 +09:00
Classic298andShigekiTsuchiyama ba78ebea27 fix: validate folder_id ownership on chat create + folder-update endpoints (#24588)
POST /api/v1/chats/new and POST /api/v1/chats/{id}/folder accepted a
caller-supplied folder_id with no validation — neither ownership, nor
existence, nor UUID format. The row was persisted with the supplied
value verbatim, so the DB ended up with chat rows whose folder_id
referenced another user's folder, a non-existent UUID, or even a
non-UUID string.

No read path surfaces this across users — every chat-folder read is
user_id-filtered on both sides — so this is referential-integrity
hardening rather than a security boundary fix. But there's no reason
to accept dangling references either, and the downstream consumers
shouldn't have to assume the column is clean.

Add a Folders.get_folder_by_id_and_user_id() lookup at both writers:
if a folder_id is supplied, it must match a folder owned by the
caller. None remains allowed (chat-without-folder is the default).
Non-existent and non-UUID values fall through to 404.

Reported by ShigekiTsuchiyama in GHSA-4vrg-2vcq-q7jc.

Co-authored-by: ShigekiTsuchiyama <ShigekiTsuchiyama@users.noreply.github.com>
2026-05-14 02:57:57 +09:00
Timothy Jaeryang Baek f607337582 refac 2026-05-14 02:56:44 +09:00
Aleix Dorca 2a7524056a i18n: Update catalan translation.json (#24569) 2026-05-14 02:49:32 +09:00
Timothy Jaeryang Baek 0214c8db24 fix: korean i18n 2026-05-14 02:41:54 +09:00
Timothy Jaeryang Baek 8aa2a42dc7 refac 2026-05-14 02:40:20 +09:00
Asbjørn Dyhrberg Thegler f6d8e1e38d Fix translation for 'Authentication' in Danish (#24645) 2026-05-14 02:36:43 +09:00
Timothy Jaeryang Baek 7dcd932ad7 refac 2026-05-14 02:34:19 +09:00
Daniel NylanderandDaniel Nylander be301c1c27 i18n: Update Swedish (sv-SE) translation — merge with latest dev (#24665)
Co-authored-by: Daniel Nylander <daniel@danielnylander.se>
2026-05-14 02:30:10 +09:00
Taey 5ab32a5086 fix: German i18n translations (#24668) 2026-05-14 02:29:33 +09:00
Timothy Jaeryang Baek 42c86e7283 refac 2026-05-14 02:25:16 +09:00
Timothy Jaeryang Baek 74f95a9b0d refac 2026-05-14 02:08:35 +09:00
Timothy Jaeryang Baek 0b81520e07 refac 2026-05-14 02:08:01 +09:00
Timothy Jaeryang Baek 4e78b355ef refac 2026-05-14 02:00:23 +09:00
Timothy Jaeryang Baek d74ee34d91 refac 2026-05-14 01:49:34 +09:00
Timothy Jaeryang Baek 3127f1b462 refac 2026-05-14 01:43:33 +09:00
Timothy Jaeryang Baek ab0ee858b7 refac 2026-05-14 00:07:55 +09:00
Timothy Jaeryang Baek 32a417bbf6 refac 2026-05-13 22:52:09 +09:00
Timothy Jaeryang Baek e7d2ddbb1d refac 2026-05-13 22:51:23 +09:00
Timothy Jaeryang Baek 171150c1e1 refac 2026-05-13 22:46:00 +09:00
Timothy Jaeryang Baek 2ad327a4dc refac 2026-05-13 22:44:17 +09:00
Timothy Jaeryang Baek d6dae36da0 refac 2026-05-13 22:39:51 +09:00
Timothy Jaeryang Baek c2cbc47ca7 feat: knowledge directory 2026-05-13 22:37:53 +09:00
_00_ 8ace73da84 upd:i18n: es-ES Translation update v0.9.5 (#24651)
es-ES Translation. Update v0.9.5

Added translation of new strings.
2026-05-13 21:45:44 +09:00
Timothy Jaeryang Baek 3b00e5721a refac 2026-05-13 16:02:11 +09:00
Timothy Jaeryang Baek 2f642754ac refac 2026-05-13 16:00:56 +09:00
Timothy Jaeryang Baek 9ef579ce4b refac 2026-05-13 15:58:52 +09:00
Timothy Jaeryang Baek ecec86dd32 refac 2026-05-13 15:55:21 +09:00
Timothy Jaeryang Baek 5b125c24d4 feat: kb_exec 2026-05-13 15:41:30 +09:00
Timothy Jaeryang Baek bc244fdc90 refac 2026-05-13 12:44:12 +09:00
Timothy Jaeryang Baek 6d0295588e refac: modernize type annotations (PEP 604 / PEP 585) 2026-05-12 17:10:15 +09:00
Timothy Jaeryang Baek a59c967d7e refac: modernize imports, standardize type hints and docstrings 2026-05-12 06:30:38 +09:00
Timothy Jaeryang Baek 998c86a52b style: standardize os.environ.get to os.getenv 2026-05-12 06:12:16 +09:00
Timothy Jaeryang Baek 83736e8dbd style: ruff format 2026-05-12 06:12:06 +09:00
Timothy Jaeryang Baek 4cee7c29a5 refac 2026-05-12 06:01:12 +09:00
Timothy Jaeryang Baek 1413ce4a52 refac 2026-05-12 05:30:25 +09:00
Timothy Jaeryang Baek c79ff81f55 refac: remove dead peewee connection wrappers 2026-05-12 04:14:19 +09:00
Timothy Jaeryang Baek 2c2d06c31b refac: deprecate peewee migration layer
The Alembic init migration (7e5b5dc7342b) already creates the
equivalent schema. Peewee migrations are no longer needed for
any version >= 0.3.6.
2026-05-12 03:45:59 +09:00
Timothy Jaeryang Baek 4527c747b4 chore: remove unused legacy test suite 2026-05-12 03:35:51 +09:00
Timothy Jaeryang Baek 9c18e707ce chore: remove unused cypress tests 2026-05-12 03:31:57 +09:00
Timothy Jaeryang Baek 070ab26501 refac: reorganize scripts and ci workflows 2026-05-12 03:26:18 +09:00
Timothy Jaeryang Baek 90a02e623f refac 2026-05-12 03:04:35 +09:00
Timothy Jaeryang Baek 18ff358c15 refac 2026-05-11 03:30:12 +09:00
Tim Baek 3660bc00fd Merge pull request #24492 from open-webui/dev
0.9.5
2026-05-10 14:13:55 -04:00
Timothy Jaeryang Baek 41b48b54aa doc: changelog 2026-05-11 02:54:22 +09:00
Timothy Jaeryang Baek 4856ce48be chore: format 2026-05-11 02:51:59 +09:00
Timothy Jaeryang Baek 0037baeb26 enh: channels streaming agent 2026-05-11 02:50:30 +09:00
Timothy Jaeryang Baek c951b4f262 chore: format 2026-05-11 02:29:13 +09:00
Timothy Jaeryang Baek 39777e35d8 doc: changelog 2026-05-11 02:28:38 +09:00
Timothy Jaeryang Baek 15e696691c refac 2026-05-11 02:25:11 +09:00
Timothy Jaeryang Baek 3a21b334cc refac 2026-05-11 02:15:46 +09:00
Timothy Jaeryang Baek 2dbf7b6764 refac 2026-05-11 02:12:38 +09:00
Timothy Jaeryang Baek 4a1064cefd refac 2026-05-11 02:04:09 +09:00
Timothy Jaeryang Baek 315566064a refac 2026-05-11 01:57:50 +09:00
Timothy Jaeryang Baek 3bba1c2270 feat: add IFRAME_CSP env var for srcdoc iframe content security policy
Adds an IFRAME_CSP environment variable that injects a Content-Security-Policy
<meta> tag into all srcdoc iframes rendering untrusted content:
- Artifacts (LLM-generated HTML previews)
- FullHeightIframe (tool/embed output)
- FilePreview (user-uploaded HTML files)
- CitationModal (RAG document HTML)

Shared utility in src/lib/utils/csp.ts handles injection with HTML-safe
attribute escaping. URL-based iframes (src=) are correctly excluded.

Env-var only — no PersistentConfig, no admin UI, no DB. Set once at deploy
time, requires restart. Empty string (default) means no CSP restriction.
2026-05-11 01:56:02 +09:00
Timothy Jaeryang Baek 1388f4568b refac 2026-05-11 01:46:33 +09:00
Timothy Jaeryang Baek d1ef538237 refac 2026-05-11 01:31:46 +09:00
Classic298andyantongggg fc94118b2d fix: prevent mass-assignment user_id spoofing in POST /api/v1/evaluations/feedback (#24508)
* fix: prevent mass-assignment user_id spoofing in POST /api/v1/evaluations/feedback

Two independent gaps in backend/open_webui/models/feedbacks.py let an
authenticated caller forge the `user_id` (and `id`, `version`) on a new
feedback record submitted to POST /api/v1/evaluations/feedback:

1. `FeedbackForm` declared `model_config = ConfigDict(extra='allow')`,
   so Pydantic preserved any extra fields supplied in the request body —
   including `user_id`, `id`, `version`. The form is the public input
   boundary for the endpoint and should not accept unknown fields.

2. In `insert_new_feedback`, the dict literal placed
   `**form_data.model_dump()` AFTER `'id': id`, `'user_id': user_id`,
   `'version': 0`. Python dict-literal duplicate-key resolution is
   last-wins, so any of those fields present in `form_data` overwrote
   the server-derived values.

Combined effect: a regular user could POST a feedback record with an
arbitrary `user_id`, attributing the rating to any other user. The Elo
leaderboard at backend/open_webui/routers/evaluations.py computes model
rankings from these records, and the admin export
(GET /api/v1/evaluations/feedbacks/export) and admin list
(GET /api/v1/evaluations/feedbacks/all) display the spoofed attribution.

Two fixes, defense-in-depth:

- FeedbackForm: switch `extra='allow'` to `extra='ignore'` so Pydantic
  drops unknown fields at parse time. Sub-models (RatingData / MetaData /
  SnapshotData) intentionally keep `extra='allow'` because their contents
  are deliberately schema-flexible — the spoofing surface was the form,
  not the sub-payloads.

- insert_new_feedback: spread `form_data.model_dump()` first, then
  overlay server-controlled fields (`id`, `user_id`, `version`,
  `created_at`, `updated_at`) so the explicit keys win on duplicate-key
  resolution regardless of what reaches the function. Matches the secure
  pattern already used in backend/open_webui/models/functions.py:120.

Reported by yantongggg in GHSA-rjmp-vjf2-qf4g. Same root-cause class as
the prior published GHSA-hr43-rjmr-7wmm (folder mass-assignment, fixed
in v0.9.0); that fix did not generalize across the codebase, this fix
closes the feedback variant.

Co-authored-by: yantongggg <yantongggg@users.noreply.github.com>

* chore: trim comments

---------

Co-authored-by: yantongggg <yantongggg@users.noreply.github.com>
2026-05-11 01:16:17 +09:00
Timothy Jaeryang Baek 8d3133fe28 refac 2026-05-11 01:15:34 +09:00
d11e06f1b7 fix: prevent redirect-based SSRF and enforce collecton write access (#24524)
* fix: prevent redirect-based SSRF in get_image_base64_from_url

Cohort follow-up to PR #24491. That PR patched three call sites
(SafeWebBaseLoader._scrape, get_content_from_url, load_url_image) to
pass allow_redirects=False on the underlying HTTP client; this fourth
call site in utils/files.py was missed.

get_image_base64_from_url() is invoked from convert_url_images_to_base64
in utils/middleware.py on every /api/chat/completions request whose
message content includes an image_url part. validate_url() is called on
the originally-submitted URL only; the aiohttp session.get() call had
no allow_redirects argument and the shared session pool does not
override the aiohttp default (allow_redirects=True). An authenticated
user sending a chat message with image_url pointing at an attacker host
that 302-redirects to 169.254.169.254 / 127.0.0.1 / RFC1918 reached the
internal target. This is the most reachable variant in the redirect
cluster: no special endpoint, no admin permission, no feature flag.

Apply the same one-line fix as the other three call sites: pass
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS (defaults to False).

Reported by nayakchinmohan in GHSA-88jq-grjp-jx6f; consolidated under
GHSA-rh5x-h6pp-cjj6.

Co-authored-by: nayakchinmohan <nayakchinmohan@users.noreply.github.com>

* fix: enforce collection write access on process_file endpoint

Cohort follow-up to ba83613ff. That commit added _validate_collection_access
to process_text and process_web (the user-supplied collection_name path)
but missed process_file in the same router.

process_file accepts a user-supplied collection_name and writes the file's
embedded content into that collection via save_docs_to_vector_db. The
file_id is gated by file ownership (line 1562) but collection_name was
unchecked, so an authenticated user could append content from a file they
own into another user's knowledge-base collection by passing the victim's
KB UUID as collection_name. Identical pattern to the process_text and
process_web gaps that ba83613ff closed.

Apply the same one-line gate as the sibling endpoints: when
collection_name is user-supplied (not the default file-{file.id} fallback),
require write access via _validate_collection_access. The shared validator
delegates to filter_accessible_collections, which already correctly
handles file-* prefixes (via has_access_to_file) and KB UUIDs
(via Knowledges.check_access_by_user_id) — admins bypass.

Reported by tenbbughunters (Tenable) in GHSA-4g37-7p2c-38r9 (the
comprehensive write-path filing covering process_text / process_file /
process_web / process_youtube and the _validate_collection_access UUID
root cause), and independently re-identified for the missed process_file
call site by kodareef5 in GHSA-4m74-3cmc-293g.

Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com>
Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com>

* fix: enforce collection write access on process_files_batch endpoint

Cohort follow-up to ba83613ff and the prior process_file fix on this
branch. process_files_batch (line 2604) is the third write endpoint in
the same router that accepts a user-supplied collection_name; it was
covered in the same Tenable filing as process_file and was missed by
the same cohort fix. The endpoint validates per-file ownership at line
2642 but does not check whether the caller has write access to the
target collection_name before save_docs_to_vector_db writes into it
at line 2683-2690 with add=True.

Apply the same one-line gate as the sibling endpoints. Validate only
when collection_name is user-supplied (truthy) so the existing fall
through behavior for the None case is unchanged.

Same Tenable / kodareef5 cohort as the previous commit.

Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com>
Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com>

---------

Co-authored-by: nayakchinmohan <nayakchinmohan@users.noreply.github.com>
Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com>
Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com>
2026-05-11 01:09:15 +09:00
Classic298andKadirArslan 841c9045d7 fix: gate tool content updates behind workspace.tools to match create endpoint (#24513)
* fix: gate tool content updates behind workspace.tools to match create endpoint

`update_tools_by_id` (routers/tools.py:452) authorizes a caller as long as
they are the tool's owner, hold a `write` access grant on the tool, or are
an admin. This means a verified user who has been given a write grant on
a tool — typically as part of a metadata-collaboration workflow (edit
description, adjust valves, manage access grants) — can also overwrite
the tool's Python source. Because `load_tool_module_by_id` further down
calls `exec(content, module.__dict__)` at module-import time, anything
the new content puts outside the `class Tools:` body executes immediately
on the server with the worker's privileges (root in the default Docker
deployment).

The `create_new_tools` endpoint already requires
`workspace.tools` (or `workspace.tools_import`) precisely because creating
a tool means submitting executable code. The update endpoint did not
mirror that check, producing an asymmetric authorization surface in which
a write-grantee with no workspace permission can still reach the same
exec sink as a workspace.tools-trusted creator. SECURITY.md frames
`workspace.tools` as the trust signal an admin uses to delegate
code-execution capability; the previous behavior let that signal be
bypassed by a per-resource share.

Fix: after the existing ownership / write-grant / admin gate, add a
content-change check. If `form_data.content != tools.content`, require
`workspace.tools` or `workspace.tools_import` (or admin role). Metadata
edits — `name`, `description`, valves config, access grants — continue
to flow through the existing gate, so the legitimate share-for-
collaboration workflow is unaffected.

Reported by KadirArslan in GHSA-p4fx-23fq-jfg6 with a working three-user
PoC (Alice trusted with workspace.tools creates a tool and shares write
to Bob; Bob updates content and the new code runs as root inside the
container, with Burp Collaborator confirming outbound exfiltration).

Co-authored-by: KadirArslan <KadirArslan@users.noreply.github.com>

* chore: trim comment

---------

Co-authored-by: KadirArslan <KadirArslan@users.noreply.github.com>
2026-05-11 01:08:12 +09:00
Classic298 f5e110fbee fix: enforce message ownership in group/DM channel update + delete endpoints (#24506)
* fix: enforce message ownership in group/DM channel update + delete endpoints

`update_message_by_id` (channels.py:1348) and `delete_message_by_id`
(channels.py:1550) branch on `channel.type`. The `else` branch (standard
channels) correctly enforces `message.user_id != user.id` ownership before
mutating, but the `if channel.type in ['group', 'dm']` branch only checked
`is_user_channel_member` — channel membership alone, with no message
ownership verification.

Effect on group/DM channels: any verified member of the conversation could:

- overwrite another member's message content while the server preserved
  `user_id=victim`, producing tampered content that renders to other
  members as the original author's authentic post (integrity + authenticity);
- silently delete another member's messages, removing them from
  conversation history without trace (integrity).

Reproduced end-to-end against v0.9.4 with three users (attacker, victim,
viewer) sharing a group channel: attacker overwrites victim's message and
deletes another, viewer reads the tampered content as victim-authored.

Two patches, identical shape, mirror the `else` branch's existing
ownership semantics:

- `update_message_by_id` group/DM branch: add
  `if user.role != 'admin' and message.user_id != user.id: raise 403`
  immediately after the `is_user_channel_member` check.
- `delete_message_by_id` group/DM branch: same.

The standard-channel branch is unchanged (it already enforced ownership).
Admins remain able to moderate any message, matching the existing semantic
in the standard-channel branch.

Reports consolidated under GHSA-wwhq-cx22-f7vv (earliest live filing of the
group/DM-specific variant). Same gap previously surfaced and partially
fixed under GHSA-jxwr-g6r6-j3fx (which addressed the standard-channel
branch only) — this completes the cohort.

* chore: trim comments
2026-05-11 01:03:39 +09:00
Timothy Jaeryang Baek e8e9141061 refac 2026-05-11 01:02:45 +09:00
Classic298andkikayli d3737176bc fix: require write permission for pin_channel_message on standard channels (#24521)
`pin_channel_message` (channels.py:1242) checked `permission='read'` on
the standard-channel branch before mutating `is_pinned` / `pinned_by` /
`pinned_at` via `Messages.update_is_pinned_by_id`. Pin/unpin is a write
operation; gating it on read access let any user with read-only channel
access pin or unpin any message in the channel, including admin posts.

One-character fix: change `permission='read'` to `permission='write'`.

Reported by kikayli in GHSA-5gc6-xhv4-2wg6.

Co-authored-by: kikayli <kikayli@users.noreply.github.com>
2026-05-11 00:59:50 +09:00
Classic298andbrodmart 2d9939ed49 chore: add validate_url() to get_image_data() for cohort consistency hardening (#24518)
* chore: add validate_url() to get_image_data() for cohort consistency hardening

`get_image_data()` in `backend/open_webui/routers/images.py` fetches the
URL returned by the configured image generation API directly via
`session.get(data)` without first calling `validate_url()`. The sibling
`load_url_image()` in the same file (called from /images/edit) calls
`validate_url(data)` first — that gate was added under
GHSA-jgx9-jr5x-mvpv. The two functions handle structurally identical
input (an attacker-or-server-supplied URL string) and should enforce the
same SSRF gate as a matter of code hygiene.

In the current call graph, the URL passed to `get_image_data()` comes
from the admin-configured image generation API's response, so an
exploitable SSRF chain additionally requires admin-side trust delegation
(misconfigured/untrusted upstream image API, or a custom
OpenAI-compatible server that reflects user input into response URLs).
That makes the missing call a defense-in-depth gap rather than a
vulnerability per SECURITY.md Rule 9 — a position the GHSA-h7cc-wwjp-5xqh
advisory is being closed under. This change is hardening: it brings the
two image-fetch helpers into alignment so any future caller that begins
passing user-influenced URLs into `get_image_data()` is gated by the
same private-IP / loopback / metadata-IP filter the rest of the
codebase enforces.

Surface raised by brodmart in GHSA-h7cc-wwjp-5xqh.

Co-authored-by: brodmart <brodmart@users.noreply.github.com>

* chore: trim comment

---------

Co-authored-by: brodmart <brodmart@users.noreply.github.com>
2026-05-11 00:59:32 +09:00
Classic298anddestination-one c66c273f62 fix: strip model params for read-only callers on per-id endpoint (#24525)
GET /api/v1/models/model?id=<model_id> at routers/models.py:412
returned the full model.model_dump() to any caller with read access,
including the params dict that holds the admin-curated system prompt
and other behavior config. The user-facing /api/models endpoint
already strips this via utils/models.py:170,210 with the comment
"Remove params to avoid exposing sensitive info", and /api/v1/models/list
gates by write permission so non-curators don't see the model in their
workspace listing at all. The per-id endpoint missed the same gate, so
a user with read-only access (e.g. granted access to use the model in
chat) could open /workspace/models/edit?id=<not-mine> in the browser
and read the system prompt verbatim from the network response, even
though saving was correctly blocked.

Compute write_access once at the top of the handler so it can serve
both the response-shape decision and the response field. When the
caller lacks write access, replace params with an empty dict in the
serialised response. Owners, admins under BYPASS_ADMIN_ACCESS_CONTROL,
and explicit write-grant holders still get the full payload so the
workspace edit UI keeps working for users who legitimately curate the
model.

Read-permission users continue to receive everything else they need to
chat with the model — the chat path resolves prompt/params server-side
from the stored ModelModel and never echoes them back through this
endpoint.

Reported by destination-one in GHSA-h2cw-7qw9-56xr.

Co-authored-by: destination-one <destination-one@users.noreply.github.com>
2026-05-11 00:59:08 +09:00
e7ba8978c6 fix: reject parser-confusing chars in validate_url to close SSRF bypass (#24534)
urllib.parse.urlparse and requests/aiohttp disagree on how to split URLs
containing backslash, tab, CR, or LF in or around the netloc. urlparse
treats backslash as part of userinfo and uses what follows '@' as the
host; requests treats backslash as the start of the path and connects
to whatever precedes it. The same URL therefore passes the private-IP
filter (urlparse sees a public host) but reaches an internal target
(requests connects to e.g. 127.0.0.1). End result is an SSRF that the
existing IP block list cannot catch because it's evaluating the wrong
host.

PoC: http://127.0.0.1:6666\@1.1.1.1 — urlparse hostname is 1.1.1.1
(global, passes), requests reaches 127.0.0.1 (loopback).

Reject up front any URL containing one of the four documented parser-
confusing characters before either parser gets a chance to interpret
it. None of these characters is valid in an unencoded URL (\ should
always be %5C, whitespace should be %09 / %0A / %0D), so this is a
pure defensive rejection with no legitimate-input false positives.

Reported by Fushuling and RacerZ-fighting in GHSA-8w7q-q5jp-jvgx.

Co-authored-by: Fushuling <Fushuling@users.noreply.github.com>
Co-authored-by: RacerZ-fighting <RacerZ-fighting@users.noreply.github.com>
2026-05-11 00:57:48 +09:00
joaoback 5b13e3e3f0 i18n: add pt-BR translations for newly added UI items and consistency pass (#24503)
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-05-11 00:57:18 +09:00
Timothy Jaeryang Baek b0a56375d2 chore 2026-05-09 23:38:32 +09:00
Timothy Jaeryang Baek 8689f7090f chore: format 2026-05-09 23:22:05 +09:00
Timothy Jaeryang Baek 55535a8965 doc: changelog 2026-05-09 23:21:08 +09:00
Classic298 203ec29baf chore: remove unauthenticated dead-code GET /api/v1/retrieval/ status endpoint (#24497)
The `get_status()` handler at retrieval.py:263 (`@router.get('/')`) returned
the live RAG pipeline configuration (CHUNK_SIZE, CHUNK_OVERLAP, RAG_TEMPLATE,
RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_MODEL, RAG_RERANKING_MODEL, etc.) without
any authentication dependency, while every adjacent endpoint on the same
router (/embedding, /embedding/update, /config, /config/update) requires
get_admin_user.

Exhaustive search of the repository confirms the endpoint has no callers:

- Frontend (src/): no `RETRIEVAL_API_BASE_URL}/'`-style fetch; the existing
  `getRAGConfig()` in src/lib/apis/retrieval/index.ts targets `/config`,
  not the root, and is the only consumer of admin-level retrieval state.
- Backend self-references: none.
- Cypress e2e (chat, documents, registration, settings): none.
- Backend tests (backend/open_webui/test/): none.
- Build/CI scripts (scripts/): none.
- Direct symbol import of `get_status` from this router: none.

The endpoint is dead code, almost certainly a relic from before the
/config GET split. Removing it has zero UX impact and eliminates the
unauthenticated-config-disclosure surface raised in advisory triage on
GHSA-65pg-qhhw-mxwg. External monitoring scripts that may have hit the
bare root will receive a 404 and can switch to the existing /config
endpoint, which returns the same fields plus the rest of the RAG config
under admin auth.

Surface raised by 0xRyuzak1 in GHSA-65pg-qhhw-mxwg. The advisory was closed
as not-a-vulnerability per SECURITY.md Rule 1 (no security boundary
crossed in default config — RAG_TEMPLATE default is a citation-format
instruction, not a system prompt; no integrity/availability impact); this
removal is independent code-hygiene that aligns the router cohort.

Reported-by: 0xRyuzak1 <https://github.com/0xRyuzak1>
2026-05-09 23:19:14 +09:00
Classic298andMatteo Panzeri 9918ab6265 fix: gate public sharing of skills behind sharing.public_skills on create/update (#24494)
The /create (L155-193) and /id/{id}/update (L248-297) endpoints in
routers/skills.py persisted form_data.access_grants directly to
AccessGrants.set_access_grants without filter_allowed_access_grants,
while every other shareable resource in the codebase (channels, knowledge,
models, notes, prompts, tools, calendars) and the dedicated
/id/{id}/access/update endpoint on this same router (L309-348) all do call
the filter. A user with workspace.skills permission (default False, but
admins can grant it to skill-creating users) could therefore attach
{"principal_type":"user","principal_id":"*","permission":"read"|"write"}
to the create or update payload and have it persisted unfiltered, bypassing
the sharing.public_skills gate that the rest of the cohort enforces.

Two changes:

- create_new_skill: call filter_allowed_access_grants with
  'sharing.public_skills' immediately before insert, after the existing
  permission check and ID-taken check.
- update_skill_by_id: call filter_allowed_access_grants with the same key
  after the access check, before form_data.model_dump() flows into
  Skills.update_skill_by_id. The pre-existing access check at L263-277 only
  restricts WHO may modify the skill; the new filter restricts WHICH grants
  they may set.

All supporting plumbing was already in place from prior PRs:
filter_allowed_access_grants is already imported at L22, the
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING constant exists,
DEFAULT_USER_PERMISSIONS['sharing']['public_skills'] is wired up,
SharingPermissions.public_skills is in the Pydantic, and the admin UI
already renders the toggle. This is a pure 2-line router fix that closes
the cohort-consistency gap.

Same shape as the calendar fix in #24493, reported by Matteo Panzeri while
auditing the resource-cohort cohort during follow-up on #24493.

Co-authored-by: Matteo Panzeri <28739806+matte1782@users.noreply.github.com>
2026-05-09 23:19:03 +09:00
Classic298andMatteo Panzeri 8a0018cf96 fix: gate public sharing of calendars behind sharing.public_calendars permission (#24493)
* fix: gate public sharing of calendars behind sharing.public_calendars permission

The calendar router did not call filter_allowed_access_grants on either the
create or update endpoint, while every other shareable resource in the
codebase (channels, knowledge, models, notes, prompts, skills, tools) does.
A verified non-admin owner could therefore attach
`{"principal_type":"user","principal_id":"*","permission":"read"|"write"}`
to their own calendar in the create or update payload and have it persisted
unfiltered. Any other verified user with the (default-on) features.calendar
permission could then read or, for write grants, write events on it via the
existing /events* endpoints, bypassing the per-user sharing.public_<X>
permission gate the rest of the resource cohort enforces.

Three changes:

- config.py: add USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING (default
  False, env-overridable) and surface it in DEFAULT_USER_PERMISSIONS
  ['sharing']['public_calendars'] so admins can grant it per group via the
  same UI used for notes/models/etc.
- routers/calendar.py: import filter_allowed_access_grants and call it in
  create_calendar with the new sharing.public_calendars key, identical to
  the channel router's pattern.
- routers/calendar.py: call filter_allowed_access_grants in update_calendar
  too. The pre-existing owner-only gate at L350 only restricts WHO may
  change grants; the new filter restricts WHICH grants they may set, so a
  non-admin owner cannot make their own calendar publicly readable or
  writable without the corresponding sharing permission.

Same shape as GHSA-7rjh-px4v-5w55 (channels). Reported by Matteo Panzeri.

Co-authored-by: Matteo Panzeri <28739806+matte1782@users.noreply.github.com>

* fix: expose public_calendars + features.calendar through admin permissions surface

The earlier commit added DEFAULT_USER_PERMISSIONS['sharing']['public_calendars']
and the runtime filter call, but the new key was not yet plumbed through the
admin /users/default/permissions endpoint. Without these changes the toggle
would round-trip as silently dropped:

- routers/users.py SharingPermissions: any payload POSTed to
  /default/permissions ran through `form_data.model_dump()`, and Pydantic
  drops fields not declared on the model. The new public_calendars key
  would have been stripped on every save, leaving admins unable to grant
  the permission via the UI even though the runtime filter would honor it.
- src/lib/constants/permissions.ts: the frontend's DEFAULT_PERMISSIONS dict
  is the seed shape used by the admin Groups Permissions panel; without
  the new key it could not bind a Switch component to it.
- Permissions.svelte: add a Calendars Public Sharing toggle alongside the
  Notes/Chats Public Sharing toggles, gated on the existing
  features.calendar flag (matches the pattern used for notes/chats).

Also closes a pre-existing parity gap on features.calendar: DEFAULT_USER_
PERMISSIONS['features']['calendar'] has existed since the calendar feature
shipped, and Permissions.svelte already renders a Calendar feature toggle,
but FeaturesPermissions Pydantic and the frontend defaults never knew
about it. Adding it everywhere completes the round-trip so admin saves no
longer silently drop the calendar feature flag either.

---------

Co-authored-by: Matteo Panzeri <28739806+matte1782@users.noreply.github.com>
2026-05-09 23:18:51 +09:00
Timothy Jaeryang Baek 69270e1c9e doc: changelog 2026-05-09 21:08:07 +09:00
Timothy Jaeryang Baek 2e71b3fbb8 chore: format 2026-05-09 21:07:08 +09:00
Timothy Jaeryang Baek df42d96c95 refac 2026-05-09 21:05:49 +09:00
Timothy Jaeryang Baek 2fa3b84241 chore: bump 2026-05-09 21:04:52 +09:00
Classic298 8854541508 fix: prevent redirect-based SSRF in web-fetch and image-load call sites (#24491)
validate_url() in retrieval/web/utils.py only validates the initial URL.
The HTTP clients used downstream (sync requests, sync requests via the
parent WebBaseLoader._scrape, aiohttp via load_url_image) followed 3xx
redirects by default and did not re-validate the redirect target against
the private-IP / metadata-IP block list. An authenticated user could
submit a public URL that 302-redirected to an internal address (RFC1918,
127.0.0.1, 169.254.169.254, etc.) and the redirected response was returned
to them, enabling SSRF reads of internal services and cloud metadata.

Three call sites needed allow_redirects=False to match the policy already
enforced on the async _fetch() path:

- SafeWebBaseLoader: override requests_kwargs in __init__ so that the
  inherited synchronous _scrape() path passes allow_redirects=False to
  self.session.get() (the parent WebBaseLoader uses requests' default
  allow_redirects=True).
- get_content_from_url (retrieval/utils.py): pass allow_redirects=False
  on the streamed requests.get(...) call.
- load_url_image (routers/images.py, image-edits endpoint): pass
  allow_redirects=False on the aiohttp session.get(...) call.

Reports consolidated under GHSA-rh5x-h6pp-cjj6:
- GHSA-rh5x-h6pp-cjj6 (tenbbughunters / Tenable) - sync _scrape
- GHSA-5vxg-6gmv-m2qr (YLChen-007) - load_url_image
- GHSA-hf76-c83f-63w2 (tempcollab) - aiohttp _fetch (already fixed)
- GHSA-h55f-h5fh-mvm4 (sneaXOR) - get_content_from_url
2026-05-09 21:01:45 +09:00
Timothy Jaeryang Baek 793e628ac3 refac 2026-05-09 20:59:29 +09:00
Classic298 a0268e51fc Merge pull request #24486 from Classic298/fix/notes-is-pinned-typeerror
fix: notes is_pinned TypeError on create/get
2026-05-09 20:56:06 +09:00
Tim Baek f51d2b026f Merge pull request #24483 from open-webui/dev
0.9.4
2026-05-09 03:50:05 -04:00
Timothy Jaeryang Baek 0f07af1bb8 fix: bump to 0.9.4, changelog for scroll fix 2026-05-09 16:41:01 +09:00
Timothy Jaeryang Baek 7d3efb8513 refac 2026-05-09 16:37:19 +09:00
+11 adc9076d17 0.9.3 (#24482)
* refac

* refac

* refac

* Merge pull request #24356 from Classic298/patch-1

doc/chore: Update SECURITY.md

* refac

* refac

* refac

* refac

* refac

* chore: Update SECURITY.md (#24363)

* Update SECURITY.md

* Update SECURITY.md

* Implement asynchronous database ping for health checks (#24380)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: prevent STT from blocking the uvicorn event loop (#24338)

The transcription endpoint was async but called the synchronous transcribe() function directly, blocking the single-threaded uvicorn event loop for the entire duration of inference. This caused all HTTP and WebSocket connections to stall for every user on the instance during STT processing.

- Add asyncio import

- Use async UploadFile.read() instead of synchronous file.file.read()

- Offload the blocking transcribe() call via asyncio.to_thread()

Closes #24169

* refac

* fix: open file content in new window when clicking file name in FileItemModal (#24125)

Previously, clicking the file name link did not open the file content
because the condition checked `!isPDF && item.url`, which failed for
`type === 'file'` items that use an ID-based URL path.

Update the condition to trigger on `item.type === 'file' || item.url`,
and resolve the correct URL by extracting `fileId` from `item.id` or
`item.tempId` instead of using `item.url` directly as the file
identifier.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Refactor file processing to use asyncio for transcribing, improving concurrency. (#24379)

* Apply validate_profile_image_url to ChannelWebhookForm.profile_image_url (#24370)

* refac

* refac

* refac

* refac

* refac

* fix: stream GET /chats/all to prevent OOM on large chat histories (#24461)

Convert the /chats/all endpoint from loading all user chats into memory
at once to a streaming NDJSON response that fetches chats in batches of
100. This prevents Out-of-Memory crashes for users with large chat
histories.

Backend: Added async generator that paginates through chats with
short-lived DB sessions per batch (critical for SQLite lock release).

Frontend: Updated getAllChats to consume the NDJSON stream via
ReadableStream reader, accumulating results for the export file.

Ref: open-webui#22206

* refac

* refac

* refac

* refac

* refac

* refac

* Enhance CommitSessionMiddleware to allow health probes to bypass session management, ensuring faster and more reliable responses. (#24384)

* refac

* refac

* refac

* refac

* refac

* refac

* refac: apply DOMPurify to excel and office HTML render assignments (#24468)

* I18n/improve chinese translation (#24194)

* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

* perf(prompts): filter prompt list in SQL instead of N+1 has_access loop (#24288)

get_prompts_by_user_id used to fetch every active prompt (with users +
all access grants), then call AccessGrants.has_access() once per prompt
that the user did not own. With 600+ prompts this issued ~600 extra
round-trips per request and explained the multi-second delay reported in
the GET /api/v1/prompts and /api/v1/prompts/tags endpoints for non-admin
users.

Push the access check into a single SQL query via the existing
AccessGrants.has_permission_filter (EXISTS subquery), so only accessible
rows come back from the DB. Users and access grants for the surviving
rows are still batch-fetched, no N+1 anywhere on this path.

Co-authored-by: Claude <noreply@anthropic.com>

* refac

* refac

* Update catalan translation.json (#24174)

* perf(prompts): make /tags fetch only the tags column with SQL access filter (#24287)

Non-admin GET /api/v1/prompts/tags went through get_prompts_by_user_id,
which loaded every active prompt with its full content/data/meta plus
owner records and all access grants, then ran one has_access query per
prompt that wasn't owned by the caller - all so the endpoint could
collapse the result to a sorted tag list. With 600 prompts this took
several seconds while the admin path (a single SELECT) returned in <1s.

Add Prompts.get_tags_by_user_id which selects only the tags column and
applies the same EXISTS-based access filter used by /list. Also tighten
the admin get_tags to project just the tags column instead of full rows.
The endpoint is now one DB query (plus one for groups), no row hydration,
no N+1.

Co-authored-by: Claude <noreply@anthropic.com>

* refac

* refac

* i18n: Add Tagalog (Filipino) translation (#24254)

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>

* refac

* refac

* refac

* style(env): satisfy ruff (datetime alias, line length, identity check) (#24118)

* Korean Translation Update (#24087)

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* feat: brave search llm context

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105)

asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which
violate anyio cancel-scope task-ownership rules. The MCPClient's
exit_stack contains anyio resources (streamable_http transport) that
use anyio cancel scopes. When exited from a different task, anyio raises
'Attempted to exit a cancel scope that isn't the current task's current
cancel scope' as a BaseException.

This BaseException propagates through the finally block, discards the
completed response return value, and surfaces as a 500 Internal Server
Error / 'No response returned.' - silently swallowing successful MCP
tool calls and blocking the chat endpoint.

Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect()
already catches BaseException internally (see prior commit), so no
wrapper is needed.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
Co-authored-by: Claude <noreply@anthropic.com>

* fix:image url validation and signout post (#24420)

* refac(routers): reject external URLs in profile/model image handlers

* refac(ui): centralize image URL validation in safeImageUrl helper

* refac(auths): make signout POST-only

* refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING

Restore the 302 redirect for external http(s) profile image URLs in
the user and model profile-image endpoints, but gate it behind a new
ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True).

Existing deployments that rely on external profile image forwarding
continue to work unchanged.  Operators who want to suppress the
redirect (to prevent client-side IP/UA/Referer leaks) can set the
flag to False.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* chore: format

* chore: changelog (#24358)

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* doc: changelog

* refac

* refac

* refac

* chore: format

* refac

---------

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: Athanasios Oikonomou <athoik@gmail.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Vincent Agra <agravj007@gmail.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Shamil <ashm.tech@proton.me>
Co-authored-by: Cyp <cypher9715@naver.com>
Co-authored-by: looselyhuman <fieldian@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
2026-05-09 03:17:07 -04:00
Timothy Jaeryang Baek 413dcae8a2 refac 2026-05-09 16:11:19 +09:00
Timothy Jaeryang Baek d34d4297ba chore: format 2026-05-09 16:08:22 +09:00
Timothy Jaeryang Baek 6116c6dca0 refac 2026-05-09 16:06:09 +09:00
Timothy Jaeryang Baek 93931efaa7 refac 2026-05-09 16:05:21 +09:00
Timothy Jaeryang Baek 3ccf263b10 refac 2026-05-09 15:46:33 +09:00
Timothy Jaeryang Baek 75e72ea2f9 doc: changelog 2026-05-09 15:41:58 +09:00
Classic298 b94aad2895 chore: changelog (#24358)
* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog
2026-05-09 15:26:04 +09:00
Timothy Jaeryang Baek 7bcc0e2e5c chore: format 2026-05-09 15:25:27 +09:00
Timothy Jaeryang Baek 46ff3abbb8 refac 2026-05-09 15:23:00 +09:00
Timothy Jaeryang Baek aa51ce482c refac 2026-05-09 15:21:31 +09:00
Timothy Jaeryang Baek 251b80ebec refac 2026-05-09 15:14:32 +09:00
Timothy Jaeryang Baek 4d99baa292 refac 2026-05-09 15:04:09 +09:00
Timothy Jaeryang Baek 3fcad2f627 refac 2026-05-09 08:28:29 +09:00
Timothy Jaeryang Baek 04bd0425ea refac 2026-05-09 07:56:58 +09:00
Timothy Jaeryang Baek 485d689cfd refac 2026-05-09 07:52:15 +09:00
Timothy Jaeryang Baek 85c7373f68 refac 2026-05-09 07:37:53 +09:00
Timothy Jaeryang Baek 11e076817a refac 2026-05-09 07:34:46 +09:00
Classic298 cfd2888545 fix:image url validation and signout post (#24420)
* refac(routers): reject external URLs in profile/model image handlers

* refac(ui): centralize image URL validation in safeImageUrl helper

* refac(auths): make signout POST-only

* refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING

Restore the 302 redirect for external http(s) profile image URLs in
the user and model profile-image endpoints, but gate it behind a new
ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True).

Existing deployments that rely on external profile image forwarding
continue to work unchanged.  Operators who want to suppress the
redirect (to prevent client-side IP/UA/Referer leaks) can set the
flag to False.
2026-05-09 07:33:31 +09:00
+5 adda20509c fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105)
asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which
violate anyio cancel-scope task-ownership rules. The MCPClient's
exit_stack contains anyio resources (streamable_http transport) that
use anyio cancel scopes. When exited from a different task, anyio raises
'Attempted to exit a cancel scope that isn't the current task's current
cancel scope' as a BaseException.

This BaseException propagates through the finally block, discards the
completed response return value, and surfaces as a 500 Internal Server
Error / 'No response returned.' - silently swallowing successful MCP
tool calls and blocking the chat endpoint.

Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect()
already catches BaseException internally (see prior commit), so no
wrapper is needed.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 07:15:24 +09:00
Timothy Jaeryang Baek e1dce99147 refac 2026-05-09 07:13:36 +09:00
Timothy Jaeryang Baek a938c8ae2e refac 2026-05-09 07:11:17 +09:00
Timothy Jaeryang Baek 5b80932e59 refac 2026-05-09 06:56:22 +09:00
Timothy Jaeryang Baek 2ba6b423aa refac 2026-05-09 06:50:11 +09:00
Timothy Jaeryang Baek 02f9fe7890 refac 2026-05-09 06:49:41 +09:00
Timothy Jaeryang Baek 29f6c72e87 refac 2026-05-09 06:44:42 +09:00
Timothy Jaeryang Baek bb0e6cb108 refac 2026-05-09 06:41:42 +09:00
Timothy Jaeryang Baek 6700f7bb72 feat: brave search llm context 2026-05-09 06:34:25 +09:00
Timothy Jaeryang Baek 1baf73bdd5 refac 2026-05-09 06:34:03 +09:00
Timothy Jaeryang Baek 1d892ce2c5 refac 2026-05-09 06:33:26 +09:00
Timothy Jaeryang Baek 794b97025d refac 2026-05-09 06:32:34 +09:00
Timothy Jaeryang Baek ee3b82926b refac 2026-05-09 06:25:38 +09:00
Timothy Jaeryang Baek 38a382ef88 refac 2026-05-09 06:23:51 +09:00
Timothy Jaeryang Baek 34146ab60f refac 2026-05-09 06:20:27 +09:00
Timothy Jaeryang Baek f70b0da156 refac 2026-05-09 06:16:27 +09:00
Timothy Jaeryang Baek af5628f8ef refac 2026-05-09 06:13:58 +09:00
Timothy Jaeryang Baek 9907c0a25a refac 2026-05-09 06:01:02 +09:00
+3 d78c247036 Korean Translation Update (#24087)
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-05-09 05:31:49 +09:00
Shamil ae0827cec0 style(env): satisfy ruff (datetime alias, line length, identity check) (#24118) 2026-05-09 05:30:09 +09:00
Timothy Jaeryang Baek 064fdecb67 refac 2026-05-09 05:29:15 +09:00
Timothy Jaeryang Baek bf4f44ee9c refac 2026-05-09 05:27:47 +09:00
Timothy Jaeryang Baek 212bb68a66 refac 2026-05-09 05:27:32 +09:00
+3 aff78e4958 i18n: Add Tagalog (Filipino) translation (#24254)
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-05-09 05:25:44 +09:00
Timothy Jaeryang Baek ae43562b86 refac 2026-05-09 05:24:50 +09:00
Timothy Jaeryang Baek 13693554f1 refac 2026-05-09 05:20:28 +09:00
Classic298andClaude 1a3e5ef4c1 perf(prompts): make /tags fetch only the tags column with SQL access filter (#24287)
Non-admin GET /api/v1/prompts/tags went through get_prompts_by_user_id,
which loaded every active prompt with its full content/data/meta plus
owner records and all access grants, then ran one has_access query per
prompt that wasn't owned by the caller - all so the endpoint could
collapse the result to a sorted tag list. With 600 prompts this took
several seconds while the admin path (a single SELECT) returned in <1s.

Add Prompts.get_tags_by_user_id which selects only the tags column and
applies the same EXISTS-based access filter used by /list. Also tighten
the admin get_tags to project just the tags column instead of full rows.
The endpoint is now one DB query (plus one for groups), no row hydration,
no N+1.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 05:20:13 +09:00
Aleix Dorca 26b1a3d7dc Update catalan translation.json (#24174) 2026-05-09 05:17:48 +09:00
Timothy Jaeryang Baek 3ab7b777b1 refac 2026-05-09 05:15:12 +09:00
Timothy Jaeryang Baek 1789303886 refac 2026-05-09 05:14:55 +09:00
Classic298andClaude 41107a34ca perf(prompts): filter prompt list in SQL instead of N+1 has_access loop (#24288)
get_prompts_by_user_id used to fetch every active prompt (with users +
all access grants), then call AccessGrants.has_access() once per prompt
that the user did not own. With 600+ prompts this issued ~600 extra
round-trips per request and explained the multi-second delay reported in
the GET /api/v1/prompts and /api/v1/prompts/tags endpoints for non-admin
users.

Push the access check into a single SQL query via the existing
AccessGrants.has_permission_filter (EXISTS subquery), so only accessible
rows come back from the DB. Users and access grants for the surviving
rows are still batch-fetched, no N+1 anywhere on this path.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 05:12:51 +09:00
Shirasawa 114c99ae2f I18n/improve chinese translation (#24194)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-05-09 05:11:48 +09:00
Classic298 3746339cfc refac: apply DOMPurify to excel and office HTML render assignments (#24468) 2026-05-09 05:10:31 +09:00
Timothy Jaeryang Baek 7eeff2fdf9 refac 2026-05-09 05:09:20 +09:00
Timothy Jaeryang Baek 55e7c7854b refac 2026-05-09 05:04:51 +09:00
Timothy Jaeryang Baek c978a788c8 refac 2026-05-09 05:03:38 +09:00
Timothy Jaeryang Baek 3d48596c9e refac 2026-05-09 04:56:25 +09:00
Timothy Jaeryang Baek 072d2000f3 refac 2026-05-09 04:53:47 +09:00
Timothy Jaeryang Baek 9386fc83a3 refac 2026-05-09 04:49:18 +09:00
Jacob Leksan b63da90ae4 Enhance CommitSessionMiddleware to allow health probes to bypass session management, ensuring faster and more reliable responses. (#24384) 2026-05-09 04:46:00 +09:00
Timothy Jaeryang Baek 23ff9943a9 refac 2026-05-09 04:44:20 +09:00
Timothy Jaeryang Baek 33e588cf09 refac 2026-05-09 04:39:44 +09:00
Timothy Jaeryang Baek 005df577fe refac 2026-05-09 04:36:43 +09:00
Timothy Jaeryang Baek 1b4cd705d0 refac 2026-05-09 04:36:23 +09:00
Timothy Jaeryang Baek 8ffc3d746f refac 2026-05-09 04:22:46 +09:00
Timothy Jaeryang Baek c1202a2327 refac 2026-05-09 04:17:58 +09:00
Classic298 55d1db1f38 fix: stream GET /chats/all to prevent OOM on large chat histories (#24461)
Convert the /chats/all endpoint from loading all user chats into memory
at once to a streaming NDJSON response that fetches chats in batches of
100. This prevents Out-of-Memory crashes for users with large chat
histories.

Backend: Added async generator that paginates through chats with
short-lived DB sessions per batch (critical for SQLite lock release).

Frontend: Updated getAllChats to consume the NDJSON stream via
ReadableStream reader, accumulating results for the export file.

Ref: open-webui#22206
2026-05-09 04:11:52 +09:00
Timothy Jaeryang Baek 6082e1adae refac 2026-05-09 04:03:49 +09:00
Timothy Jaeryang Baek c6763521c0 refac 2026-05-09 03:46:08 +09:00
Timothy Jaeryang Baek 7c398a625a refac 2026-05-09 03:45:56 +09:00
Timothy Jaeryang Baek cdfcbc4af6 refac 2026-05-09 03:40:23 +09:00
Timothy Jaeryang Baek 4d766a3edf refac 2026-05-09 03:19:48 +09:00
Classic298 d06e6d6ddc Apply validate_profile_image_url to ChannelWebhookForm.profile_image_url (#24370) 2026-05-09 03:19:25 +09:00
Jacob Leksan 8b78821ba4 Refactor file processing to use asyncio for transcribing, improving concurrency. (#24379) 2026-05-09 03:17:47 +09:00
Timothy Jaeryang Baek 552bbcecfa refac 2026-05-09 03:15:53 +09:00
Timothy Jaeryang Baek f152ad36b3 refac 2026-05-09 03:06:19 +09:00
Timothy Jaeryang Baek 60ea4214aa refac 2026-05-09 02:58:17 +09:00
Timothy Jaeryang Baek bc4d6eef33 refac 2026-05-09 02:56:15 +09:00
Timothy Jaeryang Baek cde72dab71 refac 2026-05-09 02:54:09 +09:00
Timothy Jaeryang Baek ff791b4814 refac 2026-05-09 02:43:07 +09:00
Timothy Jaeryang Baek 7eaecbad5a refac 2026-05-09 02:38:08 +09:00
Timothy Jaeryang Baek 0103d7e82c refac 2026-05-09 02:31:30 +09:00
Timothy Jaeryang Baek 3309f5d9f1 refac 2026-05-09 02:25:26 +09:00
Athanasios Oikonomou e451f8f63b fix: open file content in new window when clicking file name in FileItemModal (#24125)
Previously, clicking the file name link did not open the file content
because the condition checked `!isPDF && item.url`, which failed for
`type === 'file'` items that use an ID-based URL path.

Update the condition to trigger on `item.type === 'file' || item.url`,
and resolve the correct URL by extracting `fileId` from `item.id` or
`item.tempId` instead of using `item.url` directly as the file
identifier.
2026-05-09 02:09:35 +09:00
Timothy Jaeryang Baek 6dff85b9d2 refac 2026-05-09 02:08:08 +09:00
Classic298 7e275c1daa fix: prevent STT from blocking the uvicorn event loop (#24338)
The transcription endpoint was async but called the synchronous transcribe() function directly, blocking the single-threaded uvicorn event loop for the entire duration of inference. This caused all HTTP and WebSocket connections to stall for every user on the instance during STT processing.

- Add asyncio import

- Use async UploadFile.read() instead of synchronous file.file.read()

- Offload the blocking transcribe() call via asyncio.to_thread()

Closes #24169
2026-05-09 02:05:28 +09:00
Timothy Jaeryang Baek 1c1c8b18e5 refac 2026-05-09 02:04:36 +09:00
Timothy Jaeryang Baek 55a572cd39 refac 2026-05-09 02:04:26 +09:00
Timothy Jaeryang Baek 9adc0c442a refac 2026-05-09 02:02:02 +09:00
Timothy Jaeryang Baek fd3368c0bf refac 2026-05-09 01:55:51 +09:00
Timothy Jaeryang Baek ef6d4f2d6c refac 2026-05-09 01:50:58 +09:00
Timothy Jaeryang Baek 6bdc2ffa79 refac 2026-05-09 01:31:42 +09:00
Timothy Jaeryang Baek b72019db39 refac 2026-05-09 01:31:04 +09:00
Timothy Jaeryang Baek 2977910ffd refac 2026-05-09 01:25:01 +09:00
Timothy Jaeryang Baek f39f4a86ae refac 2026-05-09 01:22:25 +09:00
Timothy Jaeryang Baek 1dee67b64d refac 2026-05-09 01:21:17 +09:00
Jacob Leksan 2a18dc98ac Implement asynchronous database ping for health checks (#24380) 2026-05-09 01:20:11 +09:00
Classic298 1f977d072e chore: Update SECURITY.md (#24363)
* Update SECURITY.md

* Update SECURITY.md
2026-05-09 01:19:30 +09:00
Timothy Jaeryang Baek 4754ece4a2 refac 2026-05-09 01:17:57 +09:00
Timothy Jaeryang Baek 5c3edc2539 refac 2026-05-09 01:17:33 +09:00
Timothy Jaeryang Baek 4fe2de7864 refac 2026-05-09 01:13:16 +09:00
Timothy Jaeryang Baek a32d26e61d refac 2026-05-05 04:21:23 +09:00
Timothy Jaeryang Baek 989d5fd4e2 refac 2026-05-05 04:05:15 +09:00
Classic298 4e6a7baab7 Merge pull request #24356 from Classic298/patch-1
doc/chore: Update SECURITY.md
2026-05-05 03:45:57 +09:00
Timothy Jaeryang Baek cde21b9f6d refac 2026-05-05 03:33:47 +09:00
Timothy Jaeryang Baek 86df8bf27e refac 2026-05-05 02:41:22 +09:00
Timothy Jaeryang Baek 5bc80b145f refac 2026-05-04 23:57:20 +09:00
+2 8dae237a0b 0.9.2 (#24081)
* refac

* fix: remove reactive label from onDestroy in Markdown

* Update fi-FI translation.json (#24010)

Added missing translations.

* refac

* i18n: update ko-KR translations (conflict solved) (#23949)

* i18n: update ko-KR translations

* i18n: fix missing ko-KR translations and reviewed pr-bot recommendation

* i18n: add pt-BR translations for newly added UI items and consistency pass (#23954)

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.

* fix(utils): Switch throttle decorator to async (#23979)

After migration to async db operations, the throttle decorator also
needs to support async. Since the decorator is only used for async funcs
now, we can just switch it to async instead of supporting sync and async
at the same time.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>

* refac

* refac

* refac

* refac

* feat: add PaddleOCR-vl loader support and implement retrieval router infrastructure (#23945)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>

* refac

* refac

* Enhance image loading performance by adding preload links and setting loading attributes for logos in app.html (#24011)

* feat(ui): add citation source overflow badge (#23918)

* refac

* i18n: enhance and expand Dutch language translations (#23944)

* refac

* refac

* refac

* perf: redirect default model profile image to canonical static URL (#24015)

- Return 302 to /static/favicon.png instead of streaming the same PNG per
  model id so browsers can cache one asset for default avatars.
- Validate stored /static/ paths with decode, normpath, and /static
  prefix checks; invalid paths fall back to favicon.

Made-with: Cursor

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* feat: enhance RichTextInput configuration to prevent duplicate extensions when rich text is enabled (#24009)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* changelog (#24072)

* refactor(firecrawl): use v2 API directly (#23934)

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

* chore: bump

* perf(chats): drop redundant db.refresh after commit in update_chat_by_id (#24024)

The chat table has no computed columns (no DEFAULT, SERIAL/IDENTITY,
or TRIGGER that populate server-side values on UPDATE), and every
column modified by update_chat_by_id is set explicitly from Python
values earlier in the function. db.refresh therefore issues a SELECT
that replaces those just-written Python values with the round-tripped
database representation of the same values, which is a no-op for
functional purposes but pulls the entire chat.chat JSON blob back over
the network and through the driver's JSON decoder.

On large, active chats where chat.chat can reach tens of megabytes,
skipping the refresh measurably reduces latency and eliminates one
~JSON-sized transient allocation per write.

* refac

* chore: format

* chore: i18n

* refac

---------

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-04-24 05:56:03 -04:00
Timothy Jaeryang Baek 4e2240aada refac 2026-04-24 18:55:39 +09:00
Timothy Jaeryang Baek f93d20ac42 chore: i18n 2026-04-24 18:48:45 +09:00
Timothy Jaeryang Baek 8ff7ff459b chore: format 2026-04-24 18:48:21 +09:00
Timothy Jaeryang Baek f48b8ffbf0 refac 2026-04-24 18:38:57 +09:00
Constantine 3560d2f630 perf(chats): drop redundant db.refresh after commit in update_chat_by_id (#24024)
The chat table has no computed columns (no DEFAULT, SERIAL/IDENTITY,
or TRIGGER that populate server-side values on UPDATE), and every
column modified by update_chat_by_id is set explicitly from Python
values earlier in the function. db.refresh therefore issues a SELECT
that replaces those just-written Python values with the round-tripped
database representation of the same values, which is a no-op for
functional purposes but pulls the entire chat.chat JSON blob back over
the network and through the driver's JSON decoder.

On large, active chats where chat.chat can reach tens of megabytes,
skipping the refresh measurably reduces latency and eliminates one
~JSON-sized transient allocation per write.
2026-04-24 18:34:57 +09:00
Timothy Jaeryang Baek 3aeb691d98 chore: bump 2026-04-24 18:33:27 +09:00
RomualdYTandTim Baek e0d6074cd2 refactor(firecrawl): use v2 API directly (#23934)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-04-24 18:32:08 +09:00
Classic298 b1bd3084f0 changelog (#24072) 2026-04-24 18:30:51 +09:00
Timothy Jaeryang Baek 70b28b629e refac 2026-04-24 18:29:39 +09:00
Timothy Jaeryang Baek 3d1e355df7 refac 2026-04-24 18:20:10 +09:00
Timothy Jaeryang Baek 7102a63c82 refac 2026-04-24 18:06:19 +09:00
Timothy Jaeryang Baek 1cea8ec7d4 refac 2026-04-24 17:59:45 +09:00
Timothy Jaeryang Baek a7a92d2d9b refac 2026-04-24 17:49:22 +09:00
Timothy Jaeryang Baek 2419899ac6 refac 2026-04-24 17:34:12 +09:00
Timothy Jaeryang Baek 60f67c7c17 refac 2026-04-24 17:07:23 +09:00
Timothy Jaeryang Baek 34a55d4524 refac 2026-04-24 17:06:36 +09:00
Timothy Jaeryang Baek 9771898c58 refac 2026-04-24 17:04:47 +09:00
Timothy Jaeryang Baek 752238247c refac 2026-04-24 16:47:30 +09:00
Timothy Jaeryang Baek 3e14524154 refac 2026-04-24 16:39:44 +09:00
Timothy Jaeryang Baek d8b55afb00 refac 2026-04-24 16:37:02 +09:00
Timothy Jaeryang Baek 62693938a3 refac 2026-04-24 16:36:07 +09:00
Jacob Leksan 465d6fe514 feat: enhance RichTextInput configuration to prevent duplicate extensions when rich text is enabled (#24009) 2026-04-24 16:33:46 +09:00
Timothy Jaeryang Baek d6b73ea2f2 refac 2026-04-24 16:31:02 +09:00
Timothy Jaeryang Baek 5774ab4984 refac 2026-04-24 16:26:34 +09:00
Timothy Jaeryang Baek db05fdaf83 refac 2026-04-24 16:23:28 +09:00
Timothy Jaeryang Baek d740b545a4 refac 2026-04-24 16:21:37 +09:00
Timothy Jaeryang Baek 678c44c7cd refac 2026-04-24 16:17:46 +09:00
Timothy Jaeryang Baek 5cc55e2278 refac 2026-04-24 15:51:54 +09:00
Timothy Jaeryang Baek 26711c1bcc refac 2026-04-24 15:46:08 +09:00
Jacob Leksan f2cb63140c perf: redirect default model profile image to canonical static URL (#24015)
- Return 302 to /static/favicon.png instead of streaming the same PNG per
  model id so browsers can cache one asset for default avatars.
- Validate stored /static/ paths with decode, normpath, and /static
  prefix checks; invalid paths fall back to favicon.

Made-with: Cursor
2026-04-24 15:45:10 +09:00
Timothy Jaeryang Baek a76a779c01 refac 2026-04-24 15:40:02 +09:00
Timothy Jaeryang Baek a766521933 refac 2026-04-24 15:39:12 +09:00
Timothy Jaeryang Baek 7da6b82471 refac 2026-04-24 15:35:59 +09:00
Lucas 6089de0b17 i18n: enhance and expand Dutch language translations (#23944) 2026-04-24 15:30:06 +09:00
Timothy Jaeryang Baek b87c755574 refac 2026-04-24 15:29:36 +09:00
RomualdYT b73538ece7 feat(ui): add citation source overflow badge (#23918) 2026-04-24 15:26:53 +09:00
Jacob Leksan 258e9f917b Enhance image loading performance by adding preload links and setting loading attributes for logos in app.html (#24011) 2026-04-24 15:25:54 +09:00
Timothy Jaeryang Baek 6ecba19447 refac 2026-04-24 15:21:52 +09:00
Timothy Jaeryang Baek 90584ab6f3 refac 2026-04-24 15:21:37 +09:00
58bc254809 feat: add PaddleOCR-vl loader support and implement retrieval router infrastructure (#23945)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-04-24 15:19:37 +09:00
Timothy Jaeryang Baek 0e311a95a7 refac 2026-04-24 15:16:37 +09:00
Timothy Jaeryang Baek d0e51bde5d refac 2026-04-24 15:03:29 +09:00
Timothy Jaeryang Baek 4dc5c1eb4f refac 2026-04-24 15:00:47 +09:00
Timothy Jaeryang Baek 89669f3fa1 refac 2026-04-24 14:40:17 +09:00
tcx4c70 f6bd08c852 fix(utils): Switch throttle decorator to async (#23979)
After migration to async db operations, the throttle decorator also
needs to support async. Since the decorator is only used for async funcs
now, we can just switch it to async instead of supporting sync and async
at the same time.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
2026-04-24 14:39:45 +09:00
joaoback 9b577868c8 i18n: add pt-BR translations for newly added UI items and consistency pass (#23954)
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-04-24 14:38:33 +09:00
Teay 91d9870266 i18n: update ko-KR translations (conflict solved) (#23949)
* i18n: update ko-KR translations

* i18n: fix missing ko-KR translations and reviewed pr-bot recommendation
2026-04-24 14:38:12 +09:00
Timothy Jaeryang Baek d47993385a refac 2026-04-24 14:35:15 +09:00
Kylapaallikko a4eb10269e Update fi-FI translation.json (#24010)
Added missing translations.
2026-04-24 14:33:06 +09:00
Tim Baek ef77c3d45b Merge pull request #24048 from Algorithm5838/fix/markdown-ondestroy-reactive
fix: remove reactive label from onDestroy in Markdown
2026-04-24 01:32:43 -04:00
Algorithm5838 83f3a9c543 fix: remove reactive label from onDestroy in Markdown 2026-04-23 22:26:11 +03:00
Timothy Jaeryang Baek d56d74b387 refac 2026-04-23 19:39:06 +09:00
604 changed files with 112784 additions and 69384 deletions
+13 -1
View File
@@ -13,10 +13,22 @@ OPENAI_API_KEY=''
# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080'
CORS_ALLOW_ORIGIN='*'
# Set to false to keep memory tools enabled without adding memory context to the system context.
ENABLE_MEMORY_SYSTEM_CONTEXT=true
# For production you should set this to match the proxy configuration (127.0.0.1)
FORWARDED_ALLOW_IPS='*'
# DO NOT TRACK
SCARF_NO_ANALYTICS=true
DO_NOT_TRACK=true
ANONYMIZED_TELEMETRY=false
ANONYMIZED_TELEMETRY=false
# Valkey Vector Store (requires VECTOR_DB=valkey)
# VALKEY_URL='valkey://localhost:6379'
# VALKEY_COLLECTION_PREFIX='open_webui'
# VALKEY_INDEX_TYPE='HNSW'
# VALKEY_DISTANCE_METRIC='COSINE'
# VALKEY_HNSW_M='16'
# VALKEY_HNSW_EF_CONSTRUCTION='200'
# VALKEY_HNSW_EF_RUNTIME='10'
+1 -1
View File
@@ -1 +1 @@
github: tjbck
github: open-webui
+4
View File
@@ -1 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 🔒 Report a Security Vulnerability
url: https://github.com/open-webui/open-webui/security
about: Do NOT open a public issue for security vulnerabilities, suspected vulnerabilities, or any security-related concern. Please review our Security Policy and report privately via the "Report a vulnerability" button so it can be handled as a private advisory.
+30 -36
View File
@@ -1,82 +1,76 @@
name: Feature Request
description: Suggest an idea for this project
description: Suggest a new feature or improvement
title: 'feat: '
labels: ['triage']
body:
- type: markdown
attributes:
value: |
## Important Notes
### Before submitting
Please check the **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) AND [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
It's likely we're already tracking it! If you’re unsure, start a discussion post first.
## Before Submitting
#### Scope
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
If your feature request is likely to take more than a quick coding session to implement, test and verify, then open it in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions) instead.
**We will close and force move your feature request to the Ideas section, if we believe your feature request is not trivial/quick to implement.**
This is to ensure the issues tab is used only for issues, quickly addressable feature requests and tracking tickets by the maintainers.
Other feature requests belong in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions).
If your feature request might impact others in the community, definitely open a discussion instead and evaluate whether and how to implement it.
This will help us efficiently focus on improving the project.
### Collaborate respectfully
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
### Scope Guidelines
Remember:
- Open WebUI is a **volunteer-driven project**
- It's managed by a **single maintainer**
- It's supported by contributors who also have **full-time jobs**
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
We appreciate your time and ask that you **respect ours**.
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
### Be Respectful
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
### Contributing
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
### Bug reproducibility
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help!
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
### Reproducibility
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
- type: checkboxes
id: existing-issue
attributes:
label: Check Existing Issues
description: Please confirm that you've checked for existing similar requests
description: Confirm you have searched for similar requests.
options:
- label: I have searched for all existing **open AND closed** issues and discussions for similar requests. I have found none that is comparable to my request.
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
required: true
- type: checkboxes
id: feature-scope
attributes:
label: Verify Feature Scope
description: Please confirm the feature's scope is within the described scope
description: Confirm this request belongs in Issues rather than Discussions.
options:
- label: I have read through and understood the scope definition for feature requests in the Issues section. I believe my feature request meets the definition and belongs in the Issues section instead of the Discussions.
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
required: true
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
description: Is this related to a problem? Describe the pain point clearly.
placeholder: "e.g., I'm frustrated when..."
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Desired Solution you'd like
description: Clearly describe what you want to happen.
label: Proposed Solution
description: Describe what you would like to happen.
validations:
required: true
- type: textarea
id: alternatives-considered
attributes:
label: Alternatives Considered
description: A clear and concise description of any alternative solutions or features you've considered.
description: Describe any alternative solutions or workarounds you have considered.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
description: Add any other context, mockups, or screenshots about the feature request.
+10 -5
View File
@@ -4,17 +4,22 @@ updates:
directory: '/'
schedule:
interval: monthly
target-branch: 'dev'
target-branch: dev
- package-ecosystem: pip
directory: '/backend'
schedule:
interval: monthly
target-branch: 'dev'
target-branch: dev
- package-ecosystem: 'github-actions'
- package-ecosystem: github-actions
directory: '/'
schedule:
# Check for updates to GitHub Actions every week
interval: monthly
target-branch: 'dev'
target-branch: dev
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
target-branch: dev
+34 -34
View File
@@ -18,75 +18,75 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
**Before submitting, make sure you've checked the following:**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows
- **chore**: Refactor, cleanup, or other non-functional code changes
- **docs**: Documentation update or addition
- **feat**: Introduces a new feature or enhancement to the codebase
- **fix**: Bug fix or error correction
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review.
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** A concise description of the changes is provided below.
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
- [ ] **Testing:** Manual tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
- **BREAKING CHANGE**: Changes affecting backward compatibility
- **build**: Build system or dependency changes
- **ci**: CI/CD workflow changes
- **chore**: Refactoring, cleanup, or non-functional changes
- **docs**: Documentation additions or updates
- **feat**: New features or enhancements
- **fix**: Bug fixes or corrections
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvement
- **refactor**: Code restructuring for better maintainability, readability, or scalability
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
- **test**: Adding missing tests or correcting existing tests
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
- **perf**: Performance improvements
- **refactor**: Code restructuring
- **style**: Formatting changes (whitespace, semicolons, etc.)
- **test**: Test additions or corrections
- **WIP**: Work in progress
# Changelog Entry
### Description
- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
- [Describe the changes, including motivation and impact]
### Added
- [List any new features, functionalities, or additions]
- [New features, functionalities, or additions]
### Changed
- [List any changes, updates, refactorings, or optimizations]
- [Changes, updates, refactorings, or optimizations]
### Deprecated
- [List any deprecated functionality or features that have been removed]
- [Deprecated functionality or features]
### Removed
- [List any removed features, files, or functionalities]
- [Removed features, files, or functionalities]
### Fixed
- [List any fixes, corrections, or bug fixes]
- [Bug fixes or corrections]
### Security
- [List any new or updated security-related changes, including vulnerability fixes]
- [Security-related changes or vulnerability fixes]
### Breaking Changes
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
---
### Additional Information
- [Insert any additional context, notes, or explanations for the changes]
- [Reference any related issues, commits, or other relevant information]
- [Any additional context, notes, or references to related issues/commits]
### Screenshots or Videos
- [Attach any relevant screenshots or videos demonstrating the changes]
- [Attach relevant screenshots or videos demonstrating the changes]
### Contributor License Agreement
+43
View File
@@ -0,0 +1,43 @@
# ─────────────────────────────────────────────────────────────────────────────
# Backend CI — Python formatting checks via Ruff
# Runs on pushes and PRs to main/dev when backend files change
# ─────────────────────────────────────────────────────────────────────────────
name: Python CI
on:
push:
branches: [main, dev]
paths: ['backend/**', 'pyproject.toml', 'uv.lock', '.github/workflows/backend.yaml']
pull_request:
branches: [main, dev]
paths: ['backend/**', 'pyproject.toml', 'uv.lock', '.github/workflows/backend.yaml']
concurrency:
group: backend-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Ruff format check across supported Python versions ───────────────────
format-check:
name: Ruff Format (${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12']
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install formatter
run: pip install "ruff>=0.15.5"
- name: Verify formatting
run: ruff format --check . --exclude .venv --exclude venv
- name: Detect logic errors
run: ruff check --select=F --ignore=F401,F403,F405,F541,F811,F841 --output-format=github .
-61
View File
@@ -1,61 +0,0 @@
name: Release
on:
push:
branches:
- main # or whatever branch you want to use
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Check for changes in package.json
run: |
git diff --cached --diff-filter=d package.json || {
echo "No changes to package.json"
exit 1
}
- name: Get version number from package.json
id: get_version
run: |
VERSION=$(jq -r '.version' package.json)
echo "::set-output name=version::$VERSION"
- name: Extract latest CHANGELOG entry
run: |
VERSION="${{ steps.get_version.outputs.version }}"
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.get_version.outputs.version }}" \
--title "v${{ steps.get_version.outputs.version }}" \
--notes-file /tmp/release-notes.md
- name: Upload package to GitHub release
uses: actions/upload-artifact@v4
with:
name: package
path: |
.
!.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Docker build workflow
uses: actions/github-script@v8
with:
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'docker-build.yaml',
ref: 'v${{ steps.get_version.outputs.version }}',
})
-917
View File
@@ -1,917 +0,0 @@
name: Create and publish Docker images with specific build args
on:
workflow_dispatch:
push:
branches:
- main
- dev
tags:
- v*
env:
REGISTRY: ghcr.io
jobs:
build-main-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-${{ matrix.platform }}-
latest=false
- name: Build Docker image (latest)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-main-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-cuda-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (cuda tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-cuda-${{ matrix.platform }}-
latest=false
- name: Build Docker image (cuda)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-cuda-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-cuda126-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (cuda126 tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda126,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-cuda126-${{ matrix.platform }}-
latest=false
- name: Build Docker image (cuda126)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
USE_CUDA_VER=cu126
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-cuda126-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-ollama-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (ollama tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-ollama,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-ollama-${{ matrix.platform }}-
latest=false
- name: Build Docker image (ollama)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_OLLAMA=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-ollama-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-slim-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-slim-${{ matrix.platform }}-
latest=false
- name: Build Docker image (slim)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_SLIM=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-slim-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-main-images:
runs-on: ubuntu-latest
needs: [build-main-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-main-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-cuda-images:
runs-on: ubuntu-latest
needs: [build-cuda-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-cuda-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-cuda126-images:
runs-on: ubuntu-latest
needs: [build-cuda126-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-cuda126-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda126,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-ollama-images:
runs-on: ubuntu-latest
needs: [build-ollama-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-ollama-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default ollama tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-ollama,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-slim-images:
runs-on: ubuntu-latest
needs: [build-slim-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-slim-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
copy-to-dockerhub:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# For version tags: copy version tag and major.minor tag
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
# For main branch
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
# e.g. latest-cuda -> also tag as just "cuda"
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
# Determine the source tag on GHCR
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
# Copy each destination tag
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"
+395
View File
@@ -0,0 +1,395 @@
name: Create and publish Docker images with specific build args
on:
workflow_dispatch:
push:
branches:
- main
- dev
tags:
- v*
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
jobs:
build:
runs-on: ${{ matrix.platform.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
platform:
- arch: linux/amd64
runner: ubuntu-latest
- arch: linux/arm64
runner: ubuntu-24.04-arm
variant:
- name: main
suffix: ""
build_args: ""
free_disk: false
- name: cuda
suffix: "-cuda"
build_args: "USE_CUDA=true"
free_disk: true
- name: cuda126
suffix: "-cuda126"
build_args: |
USE_CUDA=true
USE_CUDA_VER=cu126
free_disk: true
- name: ollama
suffix: "-ollama"
build_args: "USE_OLLAMA=true"
free_disk: false
- name: slim
suffix: "-slim"
build_args: "USE_SLIM=true"
free_disk: false
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
platform=${{ matrix.platform.arch }}
echo "PLATFORM_PAIR=${platform//\//-}" >> ${GITHUB_ENV}
- name: Free disk space
if: matrix.variant.free_disk
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-${{ matrix.variant.name }}-${{ matrix.platform.arch }}-
latest=false
- name: Build Docker image
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform.arch }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
${{ matrix.variant.build_args }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.variant.name }}-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [build]
if: ${{ !cancelled() }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
variant:
- name: main
suffix: ""
- name: cuda
suffix: "-cuda"
- name: cuda126
suffix: "-cuda126"
- name: ollama
suffix: "-ollama"
- name: slim
suffix: "-slim"
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
- name: Download digests
id: download
uses: actions/download-artifact@v5
with:
pattern: digests-${{ matrix.variant.name }}-*
path: /tmp/digests
merge-multiple: true
continue-on-error: true
- name: Check digests
id: check
run: |
count=$(find /tmp/digests -type f 2>/dev/null | wc -l | tr -d ' ')
echo "digest_count=$count" >> $GITHUB_OUTPUT
if [ "$count" -lt 2 ]; then
echo "::warning::${{ matrix.variant.name }}: found $count digest(s), need 2 (one per arch). Skipping merge."
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
if: steps.check.outputs.skip != 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
if: steps.check.outputs.skip != 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images
if: steps.check.outputs.skip != 'true'
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
- name: Create manifest list and push
if: steps.check.outputs.skip != 'true'
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
if: steps.check.outputs.skip != 'true'
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
notify-helm-charts:
runs-on: ubuntu-latest
needs: [merge]
if: ${{ !cancelled() && needs.merge.result == 'success' && (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')) }}
steps:
- name: Create Helm charts app token
id: helm-app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HELM_CHARTS_APP_ID }}
private-key: ${{ secrets.HELM_CHARTS_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: helm-charts
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Verify published Open WebUI image
id: image
run: |
set -euo pipefail
image_name="ghcr.io/${GITHUB_REPOSITORY,,}"
ref_name="${GITHUB_REF_NAME}"
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
image_tag="dev"
else
image_tag="${ref_name#v}"
fi
docker buildx imagetools inspect "${image_name}:${image_tag}"
echo "tag=${image_tag}" >> "${GITHUB_OUTPUT}"
- name: Dispatch Helm chart automation
uses: actions/github-script@v8
with:
github-token: ${{ steps.helm-app-token.outputs.token }}
script: |
const isDev = context.ref === 'refs/heads/dev';
const eventType = isDev
? 'open-webui-dev-image-published'
: 'open-webui-release-published';
const refName = context.ref.replace('refs/heads/', '').replace('refs/tags/', '');
const appVersion = refName.startsWith('v') ? refName.slice(1) : refName;
const payload = {
image_tag: isDev ? 'dev' : appVersion,
source_ref: context.ref,
source_sha: context.sha,
source_run_id: String(context.runId),
source_repository: context.repo.repo,
};
if (!isDev) {
payload.app_version = appVersion;
}
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: 'helm-charts',
event_type: eventType,
client_payload: payload,
});
copy-to-dockerhub:
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
needs: [merge]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"
-46
View File
@@ -1,46 +0,0 @@
name: Python CI
on:
push:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
jobs:
build:
name: 'Format Backend'
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- 3.11.x
- 3.12.x
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '${{ matrix.python-version }}'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "ruff>=0.15.5"
- name: Ruff format check
run: ruff format --check . --exclude .venv --exclude venv
@@ -1,65 +0,0 @@
name: Frontend Build
on:
push:
branches:
- main
- dev
paths-ignore:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
branches:
- main
- dev
paths-ignore:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
jobs:
build:
name: 'Format & Build Frontend'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install Dependencies
run: npm install --force
- name: Format Frontend
run: npm run format
- name: Run i18next
run: npm run i18n:parse
- name: Check for Changes After Format
run: git diff --exit-code
- name: Build Frontend
run: npm run build
test-frontend:
name: 'Frontend Unit Tests'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install Dependencies
run: npm ci --force
- name: Run vitest
run: npm run test:frontend
+63
View File
@@ -0,0 +1,63 @@
# ─────────────────────────────────────────────────────────────────────────────
# Frontend CI — Lint, format check, build, and unit tests
# Runs on pushes and PRs to main/dev, skipping backend-only changes
# ─────────────────────────────────────────────────────────────────────────────
name: Frontend Build
on:
push:
branches: [main, dev]
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
pull_request:
branches: [main, dev]
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
concurrency:
group: frontend-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Format, i18n, and production build ────────────────────────────────────
format-and-build:
name: Format & Build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install dependencies
run: npm install --force
- name: Verify code formatting
run: npm run format
- name: Verify i18n strings
run: npm run i18n:parse
- name: Ensure working tree is clean
run: git diff --exit-code
- name: Production build
run: npm run build
# ── Vitest unit tests ────────────────────────────────────────────────────
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install dependencies (frozen lockfile)
run: npm ci --force
- name: Execute test suite
run: npm run test:frontend
-255
View File
@@ -1,255 +0,0 @@
name: Integration Test
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev
jobs:
cypress-run:
name: Run Cypress Integration Tests
runs-on: ubuntu-latest
steps:
- name: Maximize build space
uses: AdityaGarg8/remove-unwanted-software@v4.1
with:
remove-android: 'true'
remove-haskell: 'true'
remove-codeql: 'true'
- name: Checkout Repository
uses: actions/checkout@v4
- name: Build and run Compose Stack
run: |
docker compose \
--file docker-compose.yaml \
--file docker-compose.api.yaml \
--file docker-compose.a1111-test.yaml \
up --detach --build
- name: Delete Docker build cache
run: |
docker builder prune --all --force
- name: Wait for Ollama to be up
timeout-minutes: 5
run: |
until curl --output /dev/null --silent --fail http://localhost:11434; do
printf '.'
sleep 1
done
echo "Service is up!"
- name: Preload Ollama model
run: |
docker exec ollama ollama pull qwen:0.5b-chat-v1.5-q2_K
- name: Cypress run
uses: cypress-io/github-action@v6
env:
LIBGL_ALWAYS_SOFTWARE: 1
with:
browser: chrome
wait-on: 'http://localhost:3000'
config: baseUrl=http://localhost:3000
- uses: actions/upload-artifact@v4
if: always()
name: Upload Cypress videos
with:
name: cypress-videos
path: cypress/videos
if-no-files-found: ignore
- name: Extract Compose logs
if: always()
run: |
docker compose logs > compose-logs.txt
- uses: actions/upload-artifact@v4
if: always()
name: Upload Compose logs
with:
name: compose-logs
path: compose-logs.txt
if-no-files-found: ignore
# pytest:
# name: Run Backend Tests
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - name: Set up Python
# uses: actions/setup-python@v5
# with:
# python-version: ${{ matrix.python-version }}
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install -r backend/requirements.txt
# - name: pytest run
# run: |
# ls -al
# cd backend
# PYTHONPATH=. pytest . -o log_cli=true -o log_cli_level=INFO
migration_test:
name: Run Migration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
# mysql:
# image: mysql
# env:
# MYSQL_ROOT_PASSWORD: mysql
# MYSQL_DATABASE: mysql
# options: >-
# --health-cmd "mysqladmin ping -h localhost"
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# ports:
# - 3306:3306
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Set up uv
uses: yezz123/setup-uv@v4
with:
uv-venv: venv
- name: Activate virtualenv
run: |
. venv/bin/activate
echo PATH=$PATH >> $GITHUB_ENV
- name: Install dependencies
run: |
uv pip install -r backend/requirements.txt
- name: Test backend with SQLite
id: sqlite
env:
WEBUI_SECRET_KEY: secret-key
GLOBAL_LOG_LEVEL: debug
run: |
cd backend
uvicorn open_webui.main:app --port "8080" --forwarded-allow-ips '*' &
UVICORN_PID=$!
# Wait up to 40 seconds for the server to start
for i in {1..40}; do
curl -s http://localhost:8080/api/config > /dev/null && break
sleep 1
if [ $i -eq 40 ]; then
echo "Server failed to start"
kill -9 $UVICORN_PID
exit 1
fi
done
# Check that the server is still running after 5 seconds
sleep 5
if ! kill -0 $UVICORN_PID; then
echo "Server has stopped"
exit 1
fi
- name: Test backend with Postgres
if: success() || steps.sqlite.conclusion == 'failure'
env:
WEBUI_SECRET_KEY: secret-key
GLOBAL_LOG_LEVEL: debug
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
DATABASE_POOL_SIZE: 10
DATABASE_POOL_MAX_OVERFLOW: 10
DATABASE_POOL_TIMEOUT: 30
run: |
cd backend
uvicorn open_webui.main:app --port "8081" --forwarded-allow-ips '*' &
UVICORN_PID=$!
# Wait up to 20 seconds for the server to start
for i in {1..20}; do
curl -s http://localhost:8081/api/config > /dev/null && break
sleep 1
if [ $i -eq 20 ]; then
echo "Server failed to start"
kill -9 $UVICORN_PID
exit 1
fi
done
# Check that the server is still running after 5 seconds
sleep 5
if ! kill -0 $UVICORN_PID; then
echo "Server has stopped"
exit 1
fi
# Check that service will reconnect to postgres when connection will be closed
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
if [[ "$status_code" -ne 200 ]] ; then
echo "Server has failed before postgres reconnect check"
exit 1
fi
echo "Terminating all connections to postgres..."
python -c "import os, psycopg2 as pg2; \
conn = pg2.connect(dsn=os.environ['DATABASE_URL'].replace('+pool', '')); \
cur = conn.cursor(); \
cur.execute('SELECT pg_terminate_backend(psa.pid) FROM pg_stat_activity psa WHERE datname = current_database() AND pid <> pg_backend_pid();')"
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
if [[ "$status_code" -ne 200 ]] ; then
echo "Server has not reconnected to postgres after connection was closed: returned status $status_code"
exit 1
fi
# - name: Test backend with MySQL
# if: success() || steps.sqlite.conclusion == 'failure' || steps.postgres.conclusion == 'failure'
# env:
# WEBUI_SECRET_KEY: secret-key
# GLOBAL_LOG_LEVEL: debug
# DATABASE_URL: mysql://root:mysql@localhost:3306/mysql
# run: |
# cd backend
# uvicorn open_webui.main:app --port "8083" --forwarded-allow-ips '*' &
# UVICORN_PID=$!
# # Wait up to 20 seconds for the server to start
# for i in {1..20}; do
# curl -s http://localhost:8083/api/config > /dev/null && break
# sleep 1
# if [ $i -eq 20 ]; then
# echo "Server failed to start"
# kill -9 $UVICORN_PID
# exit 1
# fi
# done
# # Check that the server is still running after 5 seconds
# sleep 5
# if ! kill -0 $UVICORN_PID; then
# echo "Server has stopped"
# exit 1
# fi
+74
View File
@@ -0,0 +1,74 @@
# ─────────────────────────────────────────────────────────────────────────────
# Release — Create GitHub release from CHANGELOG, trigger Docker builds
# Runs on pushes to main when package.json version changes
# ─────────────────────────────────────────────────────────────────────────────
name: Release
on:
push:
branches: [main]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# ── Create release and trigger downstream workflows ──────────────────────
publish:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@v5
- name: Abort if package.json unchanged
run: |
git diff --cached --diff-filter=d package.json || {
echo "package.json not modified — skipping release"
exit 1
}
- name: Read version
id: pkg
run: echo "version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: Extract release notes from CHANGELOG
run: |
VER="${{ steps.pkg.outputs.version }}"
awk "/^## \[${VER}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" \
CHANGELOG.md > /tmp/release-notes.md
- name: Publish GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "v${{ steps.pkg.outputs.version }}" &>/dev/null; then
echo "Release v${{ steps.pkg.outputs.version }} already exists — skipping creation"
else
gh release create "v${{ steps.pkg.outputs.version }}" \
--title "v${{ steps.pkg.outputs.version }}" \
--notes-file /tmp/release-notes.md
fi
- name: Archive source
uses: actions/upload-artifact@v4
with:
name: release-archive
path: |
.
!.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Docker build
uses: actions/github-script@v8
with:
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'docker.yaml',
ref: 'v${{ steps.pkg.outputs.version }}',
})
+1
View File
@@ -310,3 +310,4 @@ dist
cypress/videos
cypress/screenshots
.vscode/settings.json
.cptr
+580
View File
@@ -5,6 +5,586 @@ 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.10.2] - 2026-07-01
### Added
- 💭 **Streamed reasoning display.** Models that emit thinking or reasoning now show that content as it streams, and it renders correctly in the chat overview and in exported conversations. [Commit](https://github.com/open-webui/open-webui/commit/0b75445ff9a42e37640c034812d0de9b84039e60), [Commit](https://github.com/open-webui/open-webui/commit/af1c0eee89810fa4c36e3eb7e4eba6de685bd7ca), [Commit](https://github.com/open-webui/open-webui/commit/4b08d65597e5b634b7191b0bd6d28feeafcc2a48), [Commit](https://github.com/open-webui/open-webui/commit/fa2abe4cb6a085a4c9045bf8d8ff4b6beffdef6a)
- 🗂️ **Folder uploads to knowledge bases.** Dragging a folder into a knowledge base, or syncing one, now recreates its subfolder structure instead of flattening everything into loose files. [#26130](https://github.com/open-webui/open-webui/issues/26130), [Commit](https://github.com/open-webui/open-webui/commit/2ed8934f5b2bc8a11c74ef2f34cdb62ef024809e)
- 🧠 **Memory system context toggle.** Administrators can now keep memory tools available while choosing not to add stored memories to the system context, using the new 'Memory System Context' toggle in admin settings. [Commit](https://github.com/open-webui/open-webui/commit/4067e357b2ff9e2fb59866d24656e832908fb6fe)
- 🧹 **Tidier automatic memories.** Automatically saved memories now focus on enduring details like preferences and goals and skip one-off things like meals, routine events, or passing mood unless you ask to remember them. [Commit](https://github.com/open-webui/open-webui/commit/80af65c24adac5140a39a2b5687a3b669b86719f)
- 🎙️ **Speech-to-text request format.** OpenAI-compatible speech-to-text can now send audio as either a multipart upload or base64 JSON, selectable in admin audio settings. [Commit](https://github.com/open-webui/open-webui/commit/989c6c13f5d4c5cc255aec77aea14725104d9cb3)
- 🧰 **API configs via environment.** Administrators can now set per-connection Ollama and OpenAI API configurations through the "OLLAMA_API_CONFIGS" and "OPENAI_API_CONFIGS" environment variables. [Commit](https://github.com/open-webui/open-webui/commit/19d8f03bd2c64013b510f2a0eeb3513d452814a3)
- 📡 **Provider failure events.** Failed Ollama and OpenAI-compatible provider requests now emit a structured event describing the error type, provider, and status, giving administrators clearer visibility into upstream failures. [Commit](https://github.com/open-webui/open-webui/commit/4351c78b1e45bb0c5824f9d9eb911b395e343646)
- 🏟️ **Arena models via environment.** Administrators can now define evaluation arena models through the "EVALUATION_ARENA_MODELS" environment variable. [#26174](https://github.com/open-webui/open-webui/issues/26174)
- ♿ **Clearer high-contrast sidebar selection.** With high-contrast mode enabled, the currently selected chat in the sidebar now stands out with stronger colors, making it easier to tell which chat is active. [#26469](https://github.com/open-webui/open-webui/issues/26469), [Commit](https://github.com/open-webui/open-webui/commit/52ee5cb1b3aa8a8ad40b23d72d82db7c3522dbf8)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Thai, Portuguese (Brazil), Catalan, and Spanish were enhanced and expanded.
### Fixed
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section — some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
- 🔐 **Fewer unexpected logouts.** A single request returning an authorization error no longer signs you out while your session is still valid, since the app now confirms the session status before redirecting to login. [Commit](https://github.com/open-webui/open-webui/commit/56ee875e21cb3b137a5aa1eca6eb3c0731b14045)
- 🔒 **Web search domain filtering.** Domain allow and block rules for web search results are now matched against the host, closing a gap where some URLs could slip past the filter. [Commit](https://github.com/open-webui/open-webui/commit/688bda09fbe26619613aa487beda8059a5fd55ef)
- 🕵️ **Image prompt log privacy.** Image generation workflows are no longer written to server logs at the default log level, keeping user-authored prompt content out of operator-visible logs. [#26400](https://github.com/open-webui/open-webui/issues/26400), [Commit](https://github.com/open-webui/open-webui/commit/64b92ff08a09b1bf338d5d95b7bf6f9db0d7e857)
- 🗄️ **Safer database upgrades.** Upgrading an existing SQLite database no longer crashes during the user-table migration or corrupts saved user settings, resolving failures that could block startup or break login after an upgrade. [#26403](https://github.com/open-webui/open-webui/issues/26403), [Commit](https://github.com/open-webui/open-webui/commit/c416c6cad69cc4ce44faacdad87cc65862625fb4)
- ⚙️ **Saving settings as a non-admin.** Non-admin users can once again save their interface settings, such as the default model and theme, which previously failed with a server error while the interface incorrectly reported success. [#26627](https://github.com/open-webui/open-webui/issues/26627), [Commit](https://github.com/open-webui/open-webui/commit/9866a02863c12c466100aed832f0006225759493)
- 🕒 **Sidebar chat timestamps.** Chats in the sidebar now show when they were last active instead of when they were created, so the time label matches their position in the list and refreshes after each new message. [#26454](https://github.com/open-webui/open-webui/pull/26454), [#26451](https://github.com/open-webui/open-webui/issues/26451)
- 🎯 **Default model after refresh.** Your selected model is no longer cleared when you reload the page, as model selection now waits for the model list to finish loading. [Commit](https://github.com/open-webui/open-webui/commit/b6d4baeb7ea9bc83e5486ca98b3cae07d3869aa7)
- ⏳ **Tool dialogs no longer hang.** Dismissing a tool or function input dialog by clicking outside it now cancels the pending request instead of leaving the chat spinning indefinitely. [#26417](https://github.com/open-webui/open-webui/issues/26417), [Commit](https://github.com/open-webui/open-webui/commit/0016266c0652757e32aa44f2e1fb7311ac08db51)
- 🐍 **Reliable code execution loading.** Running Python code in chat now loads its runtime reliably, fixing sandbox startup failures that broke code execution and the Pyodide file viewer in recent releases. [#26625](https://github.com/open-webui/open-webui/pull/26625), [#26390](https://github.com/open-webui/open-webui/issues/26390)
- 🤖 **Models with null capabilities.** Chatting with a model whose capabilities are unset no longer fails with an error when the memory feature or automations are involved. [#26412](https://github.com/open-webui/open-webui/issues/26412), [Commit](https://github.com/open-webui/open-webui/commit/650b81792582268345073ce3757ac14356189d75), [Commit](https://github.com/open-webui/open-webui/commit/0016266c0652757e32aa44f2e1fb7311ac08db51)
- 🔎 **Searchable responses.** Chat search again finds assistant messages whose text is stored as structured output, which were previously skipped. [#26405](https://github.com/open-webui/open-webui/pull/26405)
- 🔔 **Chat notification previews.** Background chat completion notifications and toasts now show a clean response preview instead of appearing blank for messages stored as structured output. [Commit](https://github.com/open-webui/open-webui/commit/c98d8ecaccc7284573b050e88b3857c4aeba3860)
- 💾 **Banner and config startup.** Setting configuration such as "WEBUI_BANNERS" no longer causes a startup failure, since admin configuration values are now stored correctly regardless of their data type. [#26431](https://github.com/open-webui/open-webui/issues/26431), [Commit](https://github.com/open-webui/open-webui/commit/ab22fe64bdd10ba86845dcd46dfd7f619b22366f)
- 📑 **RAG Template visibility.** The RAG Template editor now stays visible in admin document settings even when Bypass Embedding and Retrieval is enabled, since the template still applies to document content in that mode. [#26126](https://github.com/open-webui/open-webui/issues/26126), [Commit](https://github.com/open-webui/open-webui/commit/8fe480250f649824441a7b4d725a502683134713)
- 🧬 **Editing derived models.** Editing a workspace model no longer clears its base model, including when that base is a preset or the model itself. [Commit](https://github.com/open-webui/open-webui/commit/092b5857bbda01be53619e26f42b1fa9b64a2b44), [Commit](https://github.com/open-webui/open-webui/commit/54f31c630afa75e9c81acaa048e1bd901254ba92)
### Changed
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
## [0.10.1] - 2026-06-29
### Fixed
- 🤝 **Shared folder read-only chats no longer sign users out.** Opening or reading chats from shared folders now keeps the current session active when a resource-level access error is returned, instead of incorrectly showing "Session expired. Please sign in again."
## [0.10.0] - 2026-06-29
### Added
- 🤝 **Share folders with your team.** You can now share a folder and the chats inside it with specific users, groups, or everyone, with read or write access; people you share with see shared folders in their sidebar and open the chats in a read-only view when they are not the owner, and administrators control who is allowed to share folders with a new "Folders Sharing" permission that is off by default. [Commit](https://github.com/open-webui/open-webui/commit/5019af79a0c45743ede8c9ff37d68f768e7f6174), [Commit](https://github.com/open-webui/open-webui/commit/38920c0ed1f6ad5fe3bb9d12898fa968ead3634a), [Commit](https://github.com/open-webui/open-webui/commit/d65ac445a43348c5f0323d54c37397ae7f483cb8), [Commit](https://github.com/open-webui/open-webui/commit/c783fd30f20d6be5028cf337bc5e5c2f9afbd3f8), [Commit](https://github.com/open-webui/open-webui/commit/45fcf272ef51c84cb01c1454589da3f98e4adc2c), [Commit](https://github.com/open-webui/open-webui/commit/76854d14246660af8222a5302513020e1f36c4f3), [Commit](https://github.com/open-webui/open-webui/commit/084d040e220ee39f62757d928d839646e813fb25), [Commit](https://github.com/open-webui/open-webui/commit/10558173fb155c63403aa8d80f16f8a3ccfa72a6)
- 🗜️ **Automatic context compaction for long chats.** Conversations that grow past a configurable token threshold can now be summarized automatically so they stay within a model's context window, with a notification shown while it happens; administrators can enable it, set the threshold, customize the summarization prompt, and lower the threshold per model. It is off by default. [Commit](https://github.com/open-webui/open-webui/commit/3f0c0e0a0ddff841b015f96f9649c6999a435c73), [Commit](https://github.com/open-webui/open-webui/commit/7f08376f0c06e1a7fba983a2fa93deb8dfbe7cb0), [Commit](https://github.com/open-webui/open-webui/commit/8934bfb04bf366aece872028944e280c25e36d3e), [#19594](https://github.com/open-webui/open-webui/issues/19594)
- 🖥️ **Open WebUI Computer agent support.** Open WebUI can now connect to Open WebUI Computer through its OpenAI-compatible gateway, letting chats run full agent sessions on your own machine with file, terminal, git, and web access. [GitHub](https://github.com/open-webui/computer)
- 🚀 **Much faster hybrid search on large knowledge bases.** Hybrid search now runs natively in the database on pgvector setups instead of loading an entire collection into memory, so querying large knowledge bases is dramatically faster. [Commit](https://github.com/open-webui/open-webui/commit/223f484ded01d092979693341dc03351a9fa17fa), [#20737](https://github.com/open-webui/open-webui/discussions/20737)
- 🗂️ **External knowledge bases.** Knowledge bases can now be backed by an external retrieval source through configurable external knowledge connections, so you can search an existing external system from chat instead of only Open WebUI's built-in store. [Commit](https://github.com/open-webui/open-webui/commit/15c7e374384488effc3d6059d09b3a8aa79c618d)
- 🧠 **Reworked memory system.** Memory has been overhauled with distinct memory types — long-lived personal memories and per-conversation context — managed through a structured add, update, and delete flow, giving models a more reliable way to remember and apply what they've learned about you. [Commit](https://github.com/open-webui/open-webui/commit/dbdcfd8c6080c284024052a482e589de226dbf05), [Commit](https://github.com/open-webui/open-webui/commit/7e13fd7ad19c28ba34502bde6c709665f7a6808c), [Commit](https://github.com/open-webui/open-webui/commit/2560533c1a8a2b2a0f7b47becf3703031053ad30), [Commit](https://github.com/open-webui/open-webui/commit/8977a10a2b1e150393635dc5c24e59660d0f2da9), [Commit](https://github.com/open-webui/open-webui/commit/260f3c3a22c55f15ca8f06c5314b23f2a9eb1739), [Commit](https://github.com/open-webui/open-webui/commit/b0487dd6dd942a757828a25aba92e9feef685275), [Commit](https://github.com/open-webui/open-webui/commit/a285a390c12e27e614d1ba9ffb92d1a0e49e7dfa), [Commit](https://github.com/open-webui/open-webui/commit/70e4ffcc6526c1bc90dbfdc0287283574b12c18b), [Commit](https://github.com/open-webui/open-webui/commit/2c4e1fce8f40b0cb5028f1afcb184b6e58c33041), [Commit](https://github.com/open-webui/open-webui/commit/c7e634776d7e77556d149b6cc884ed64363648a9)
- 🧩 **New plugin primitive: the Event function.** Where pipe, filter, and action functions all run inside a conversation, the new Event function is the first primitive that hooks into the system itself: it runs your own Python in response to events emitted across the whole application — sign-ups, configuration changes, file uploads, role changes, deletions, startup and shutdown, and more. That makes a new class of behavior possible directly inside Open WebUI, from onboarding and access control to auditing, lifecycle automation, and external integrations. Comes with starter boilerplate in the function editor. [Commit](https://github.com/open-webui/open-webui/commit/e124c2656a4c2092b070e570e35b2f0fb7f584de), [Docs](https://docs.openwebui.com/features/extensibility/plugin/functions/event)
- 🔔 **New event system with webhooks.** Open WebUI now emits events for a wide range of system activity — sign-ins, configuration changes, startup, and actions across chats, knowledge, files, and more. Administrators can send these as outbound webhooks, route them to specific users or groups, and manage which events go where from a new event settings admin page. [Commit](https://github.com/open-webui/open-webui/commit/b5c43968db0ea1556b228d143ae5946dc4e944ba), [Commit](https://github.com/open-webui/open-webui/commit/745396867888718289a2dfcf0809b3e162e00629), [Commit](https://github.com/open-webui/open-webui/commit/5576e6ed8a80a4032b7c6cb3ee0cda0254019355), [Commit](https://github.com/open-webui/open-webui/commit/7b55a63fc7ee323e9114713ce1d2f3f688aa37e6), [Commit](https://github.com/open-webui/open-webui/commit/1a8e1a993928a28b9d814c77b2aef0361b630f27), [Commit](https://github.com/open-webui/open-webui/commit/ede39d82de05eeb7591329679c8cab97753e5ff0), [Commit](https://github.com/open-webui/open-webui/commit/8f890f0b43aed3d42b9e3d954e25e54e37d526d0), [Commit](https://github.com/open-webui/open-webui/commit/741b64edb6c2ff04c4b787528cfca1c5b66a1a27), [Commit](https://github.com/open-webui/open-webui/commit/303c426c3fffafc2369205021a82659ee8715a85), [#1240](https://github.com/open-webui/open-webui/issues/1240), [#16426](https://github.com/open-webui/open-webui/pull/16426)
- 🔐 **Configure authentication from the admin panel.** LDAP and OAuth/OIDC settings now have a dedicated Authentication settings page, so providers can be configured from the admin interface. [Commit](https://github.com/open-webui/open-webui/commit/5cdcdbaeec9fc8156721c38c33ec37956962871c), [#12945](https://github.com/open-webui/open-webui/pull/12945)
- 🏷️ **More custom header variables.** Custom request headers now support "{{USER_MESSAGE_ID}}", "{{USER_MESSAGE_PARENT_ID}}", and "{{TASK}}", letting connected services tell apart real user messages from automated background requests like title, tag, and follow-up generation. [Commit](https://github.com/open-webui/open-webui/commit/f85cb27ef835aa76aff7de6176bf2159ba392061)
- 📄 **File details forwarded to external document extractors.** External custom document-extraction servers now receive the file's ID, name, and content type, and these are also available as custom header variables, so extraction can be tailored per file. [Commit](https://github.com/open-webui/open-webui/commit/b1c2536ed2f8639efade04618018e6de9b332df2), [#26259](https://github.com/open-webui/open-webui/issues/26259)
- 🎰 **Last model pre-selected for new slots.** When you add another model to a multi-model chat, the slot now defaults to the model you last picked instead of starting empty. [#25974](https://github.com/open-webui/open-webui/pull/25974)
- ⚡ **Faster model overview.** The admin model overview now loads its feedback history and tags through batched queries, so it opens noticeably faster on instances with many chats. [Commit](https://github.com/open-webui/open-webui/commit/40c09167cd6de1c853a5dd03c88b4fdcb279dfe1)
- 🏎️ **Lighter channel profile previews.** Profile previews in channels now load a person's details only when you hover to open one, rather than fetching them for every message up front. [Commit](https://github.com/open-webui/open-webui/commit/4f69c33de0e9a8fde4f16d0b2f1ed8aac8741772)
- ↩️ **Reset permissions to defaults.** The group and default permission dialogs now include a button to restore all permissions back to their built-in defaults in one step. [#25931](https://github.com/open-webui/open-webui/pull/25931)
- 📥 **Chat import permission.** Administrators can now control whether users are allowed to import or clone chats, with a new "Allow Chat Import" permission. [Commit](https://github.com/open-webui/open-webui/commit/edf3ae920989b01383be543e7379d3eade03c0b6), [Commit](https://github.com/open-webui/open-webui/commit/9ccda6715c3b2dc2cbc1302d2396b2f0233bdea8), [Commit](https://github.com/open-webui/open-webui/commit/ed4cb358a06fc6962b378f20ef846fd2b0af90bc), [#25927](https://github.com/open-webui/open-webui/pull/25927)
- 🔔 **Per-group user webhook permission.** Administrators can now control which users may set a personal notification webhook, with a new "User Webhooks" permission. [#25923](https://github.com/open-webui/open-webui/pull/25923)
- ✍️ **Customizable autocomplete prompt.** Administrators can now set a custom prompt template for autocomplete generation from the admin interface settings. [Commit](https://github.com/open-webui/open-webui/commit/4dbb2f94a66d6e0035e2da857ddb6a841a68f862), [#25879](https://github.com/open-webui/open-webui/pull/25879)
- 🔑 **Configurable secret key length.** The auto-generated secret key length can now be set with a new environment variable, instead of always using a fixed length. [Commit](https://github.com/open-webui/open-webui/commit/e473ab1231abedcb188c259b42ae7f2390739223), [#25906](https://github.com/open-webui/open-webui/pull/25906)
- 🏟️ **Arena evaluation models configurable via environment.** Arena evaluation models can now be defined through an environment variable, which previously could not be set that way. [Commit](https://github.com/open-webui/open-webui/commit/fd56086e793a0eceb07a55ff972a5492d8f8a285)
- ✏️ **Edit prompts from the menu.** The prompts list now has an Edit option in each prompt's menu, taking you straight to its editor. [#25789](https://github.com/open-webui/open-webui/pull/25789)
- 📋 **Clone automations.** Automations now have a Clone option in their menu, so you can duplicate one as a starting point. [#25790](https://github.com/open-webui/open-webui/pull/25790)
- 🔁 **Recurring calendar events.** The calendar event editor now includes a repeat option, so events can recur on a schedule. [#25865](https://github.com/open-webui/open-webui/pull/25865)
- 🧷 **Separate skills import and export permissions.** Administrators can now control importing and exporting skills independently, with new skills import and export permissions. [#25921](https://github.com/open-webui/open-webui/pull/25921)
- 🏷️ **Filter admin models by tag.** The admin Models settings page now has a tag filter for narrowing the model list by base-model tags. [Commit](https://github.com/open-webui/open-webui/commit/2bdd2ab94eefd3d75dd6511c445e302f82221b5d)
- 📊 **Sortable analytics chat list.** The model chat list in analytics now has sortable column headers, so you can order it by title, last updated, or user. [Commit](https://github.com/open-webui/open-webui/commit/3730a9eaac68dff60b3ae5b4ed160b91480d66eb), [#26168](https://github.com/open-webui/open-webui/pull/26168)
- 🔐 **Argon2 password hashing option.** Password hashing can now use Argon2 through a configurable algorithm setting, removing the 72-byte password length limit that came with the previous default. [Commit](https://github.com/open-webui/open-webui/commit/33cd199e6dffddd4ee8974af41ebb894871d74c1), [Commit](https://github.com/open-webui/open-webui/commit/a70a6589afad0b429cdd77afa62163391f406a87), [#25656](https://github.com/open-webui/open-webui/pull/25656)
- 🔐 **Optional encryption of valve values at rest.** Tool and function valve values can now be encrypted at rest through a new opt-in setting, with existing stored values migrated automatically, so sensitive settings like API keys aren't kept in plaintext. [Commit](https://github.com/open-webui/open-webui/commit/b4073f6378392b23a3954e33031bf5e1d98e090a), [#23721](https://github.com/open-webui/open-webui/pull/23721)
- 🗄️ **AWS RDS IAM database authentication.** The database connection can now authenticate using AWS RDS IAM tokens through a new opt-in setting, instead of only a static password. [Commit](https://github.com/open-webui/open-webui/commit/c0c6c2181a8dc57b62e8a5eabd550bf89db7ffed), [#23580](https://github.com/open-webui/open-webui/pull/23580)
- 🔓 **Automatic auth for models with OAuth 2.1 tools.** When a model uses tools that require OAuth 2.1, Open WebUI now initiates the authorization flow automatically instead of failing the request. [Commit](https://github.com/open-webui/open-webui/commit/ae5d23f2267845922c2acb507a4a432908d03b41), [#23325](https://github.com/open-webui/open-webui/pull/23325), [#23272](https://github.com/open-webui/open-webui/issues/23272)
- 🔤 **Custom tokenizer for token-based text splitting.** Token-based document splitting can now use a configurable Hugging Face tokenizer model, so chunking can match the tokenizer of the model you use. [Commit](https://github.com/open-webui/open-webui/commit/bb6b2db88b1e82395531f67db4f6accd49d8b9eb), [#24139](https://github.com/open-webui/open-webui/pull/24139)
- 🔒 **Restrict OAuth scopes requested from MCP servers.** A new setting lets administrators limit which OAuth scopes Open WebUI requests when connecting to MCP servers. [Commit](https://github.com/open-webui/open-webui/commit/7be009649a0a94008484335c492ab0af18fde41f), [#25981](https://github.com/open-webui/open-webui/pull/25981), [#25978](https://github.com/open-webui/open-webui/issues/25978)
- 🧩 **Filter Outlet Hook can now run on API requests and responses.** A filter function's outlet hook now runs for direct API callers, including streaming responses, so response post-processing isn't limited to the web interface; this is controlled by a new setting and on by default. [Commit](https://github.com/open-webui/open-webui/commit/390e200f76877b185002c88b4dda27b123d29e83), [#25650](https://github.com/open-webui/open-webui/pull/25650)
- 🖥️ **Setting for terminal sidebar auto-open.** A new interface setting controls whether the files sidebar opens automatically when you select a terminal. [Commit](https://github.com/open-webui/open-webui/commit/958237473f8cbde97eb0df8c21f2a4de088c4459), [#25628](https://github.com/open-webui/open-webui/pull/25628)
- 📌 **Reorder pinned notes by dragging.** Pinned notes in the sidebar can now be dragged to reorder them. [#25677](https://github.com/open-webui/open-webui/pull/25677)
- 🔎 **Chat actions in search.** The search dialog now offers a context menu on each result, so you can act on a chat directly from search. [#25490](https://github.com/open-webui/open-webui/pull/25490)
- 🔎 **Snippets in chat search results.** Searching your chats now shows a snippet of the matching content in each result, so you can tell results apart at a glance. [Commit](https://github.com/open-webui/open-webui/commit/0eba3df1199f56e8ac77213772a41313a3237296), [Commit](https://github.com/open-webui/open-webui/commit/67a7b23b85d2e3ce6b094b682ba9f07dc453d355), [Commit](https://github.com/open-webui/open-webui/commit/8927c9bb3d4b04f4fc8e443f42089f2a551276bc), [#25178](https://github.com/open-webui/open-webui/pull/25178)
- 📝 **Formatted valve descriptions.** Valve descriptions for tools and functions now render Markdown, so they can include formatting and links. [Commit](https://github.com/open-webui/open-webui/commit/7c0b0e42f5afb0e9c39bd2d42e4822d64d0c7b3e)
- 🔽 **Dropdown inputs for valve options.** Valve and confirmation inputs can now present a set of options as a dropdown instead of free text, making fixed-choice settings easier to configure. [Commit](https://github.com/open-webui/open-webui/commit/422a4768ea7428b5dd6d401ccfba00e1e86eb98a), [#26278](https://github.com/open-webui/open-webui/pull/26278)
- 🔌 **Control the OAuth resource parameter for MCP connectors.** MCP connectors can now be set to always send, never send, or automatically decide whether to include the OAuth resource parameter, so they work with providers that reject it. [Commit](https://github.com/open-webui/open-webui/commit/5576e6ed8a80a4032b7c6cb3ee0cda0254019355)
- 🔎 **SERPHouse web search.** SERPHouse can now be used as a web search provider. [Commit](https://github.com/open-webui/open-webui/commit/3a232f5e9a4d31a6b74cb34d007b581feeb2f005), [Commit](https://github.com/open-webui/open-webui/commit/dd4f43bfdb793c4276e6468b65fc32d9b881578a), [#26254](https://github.com/open-webui/open-webui/pull/26254)
- 🔎 **Microsoft Web IQ web search.** Microsoft Web IQ can now be used as a web search provider, with a matching page-browse loader. [#26178](https://github.com/open-webui/open-webui/pull/26178)
- ⚠️ **Optional web search confirmation.** Administrators can now require users to confirm before a web search runs, with a banner and message making it clear when search is about to be used. [Commit](https://github.com/open-webui/open-webui/commit/fa76764c3b7f99c5adacd34dabd51ead09542c13), [#24942](https://github.com/open-webui/open-webui/pull/24942)
- 🪪 **Client User-Agent forwarded to model backends.** The browser's User-Agent is now passed through to all model backends, so upstream services can see the originating client. [#26333](https://github.com/open-webui/open-webui/pull/26333)
- 🖐️ **Drag items from the sidebar into chat.** Folders, notes, and models — including pinned notes — can now be dragged from the sidebar into the chat input. [#25771](https://github.com/open-webui/open-webui/pull/25771), [Commit](https://github.com/open-webui/open-webui/commit/dc1bc41d2e), [#26384](https://github.com/open-webui/open-webui/pull/26384)
- 🏷️ **Tag suggestions in the model editor.** The model editor now suggests existing tags as you type, making it easier to reuse a consistent set. [Commit](https://github.com/open-webui/open-webui/commit/b58b0ea7ca849b89d217e1077498a8a3fc92471f), [#25703](https://github.com/open-webui/open-webui/pull/25703)
- 🗣️ **Voice suggestions in the model editor.** The model editor now offers a dropdown of available text-to-speech voices, making it easier to pick one. [Commit](https://github.com/open-webui/open-webui/commit/a5c945940134b957dbad47790b5baafeecdac6c4), [#25706](https://github.com/open-webui/open-webui/pull/25706)
- 🎛️ **Unified model picker for workspace base model.** Choosing a base model in the model editor now uses the searchable model selector instead of a plain field, making it easier to find and pick the right model. [Commit](https://github.com/open-webui/open-webui/commit/c89fd237b822877bffbb33a37402622983c7189d), [#24576](https://github.com/open-webui/open-webui/issues/24576)
- 🔍 **Searchable pickers in the model editor.** Attaching actions, filters, tools, knowledge, and skills to a model now uses type-to-search pickers instead of long checkbox lists, making large libraries easier to manage. [Commit](https://github.com/open-webui/open-webui/commit/61cee42ded4e84e31d7cc9b168994ef165efa8ca)
- 🖼️ **iPhone images work with OpenAI image editing.** Uploaded images are now normalized before being sent to OpenAI image editing, fixing edits that failed for certain iPhone photo formats, with a new admin toggle to control the behavior. [Commit](https://github.com/open-webui/open-webui/commit/39837e0a3afd17b7ff617d97dddf4c5d6446e42d), [Commit](https://github.com/open-webui/open-webui/commit/2d3035a1122123df471ac9d0a591a9465a8212e2), [#26252](https://github.com/open-webui/open-webui/pull/26252), [#26249](https://github.com/open-webui/open-webui/issues/26249)
- 🟢 **Loaded-model indicator for llama.cpp.** Models served through llama.cpp now report whether they're currently loaded in memory, including the sleeping state, so the loaded indicator works for them too. [Commit](https://github.com/open-webui/open-webui/commit/b696c5deff15d4c85c84c5bac244f062b1bc879a)
- 🧱 **Structured model output rendered on the client.** Reasoning, tool calls, and server-side tool steps such as web and file search are now rendered in the browser from the model's structured output instead of being flattened into the message text on the server, giving more accurate and editable rendering of these items. [Commit](https://github.com/open-webui/open-webui/commit/0443ab3a61492799f1aaa449f89cbd8aa5912f57), [Commit](https://github.com/open-webui/open-webui/commit/c33fadc26671190c94d86485e6e2ef2f6fd486a3)
- 📜 **Custom CA bundle for outbound connections.** A new environment variable lets you point Open WebUI at a custom CA certificate bundle, and the per-connection SSL settings now accept a bundle path, so deployments behind a corporate or internal CA can keep certificate verification on instead of disabling it. [Commit](https://github.com/open-webui/open-webui/commit/a54878b14f044d4aa1d8cf5be6f8ce9fc4285438), [Commit](https://github.com/open-webui/open-webui/commit/8b9e28b50354307a314111262f6737b6d8aa4685)
- 🖥️ **More terminal server orchestrator controls.** Admins connecting an orchestrator terminal server can now configure session lifecycle policies and refresh or reset running terminal sessions, including targeting only idle ones, from the connection settings. [Commit](https://github.com/open-webui/open-webui/commit/7e8153e889a59afe4cf77261ea5e1ef5a66665f1)
- 📁 **Terminal file browser can stay within a root folder.** The terminal file navigator now anchors to a defined root and home directory, so users can be kept within their workspace instead of browsing into system folders by accident. [Commit](https://github.com/open-webui/open-webui/commit/a0c2ec3d2cf8d696ede479211330eec2da360d39)
- 🧠 **Memory toggle follows the server default.** When a user hasn't set their own memory preference, it now follows the admin's global memory setting instead of defaulting to off. [#25909](https://github.com/open-webui/open-webui/pull/25909)
- 🧹 **Unshare all shared chats at once.** The Shared Chats dialog now has a button to stop sharing every shared chat in one action. [#25848](https://github.com/open-webui/open-webui/pull/25848)
- 📈 **Richer analytics with a date picker.** The analytics dashboard now lets you choose a date range and shows additional columns. [#25922](https://github.com/open-webui/open-webui/pull/25922), [#25919](https://github.com/open-webui/open-webui/issues/25919)
- 🔢 **Chat and file counts in their dialogs.** The Chats and Files dialogs now show the total number of chats and files in their titles. [#25872](https://github.com/open-webui/open-webui/pull/25872), [#25873](https://github.com/open-webui/open-webui/pull/25873)
- ⚡ **Faster math rendering.** Rendered math is now cached and reused, so messages with repeated or unchanged math expressions render more efficiently. [#25847](https://github.com/open-webui/open-webui/pull/25847)
- ⚡ **Lighter Markdown setup.** Markdown extension setup now runs once instead of on every render, avoiding repeated work and extension stacking. [#25837](https://github.com/open-webui/open-webui/pull/25837)
- ⚡ **Snappier read-only code blocks.** Read-only code blocks now skip language auto-detection, so they render faster. [#25824](https://github.com/open-webui/open-webui/pull/25824)
- ⚡ **Non-blocking audio model loading.** Loading speech models no longer blocks the server, keeping it responsive while they initialize. [#25806](https://github.com/open-webui/open-webui/pull/25806)
- ⚡ **Faster URL safety checks.** The safety check on fetched URLs now resolves addresses off the main loop, so it no longer blocks other work. [#25825](https://github.com/open-webui/open-webui/pull/25825)
- ⚡ **Fewer queries for channel reactions and replies.** Channel reactions and thread replies now load through batched queries, reducing database load on busy channels. [#25831](https://github.com/open-webui/open-webui/pull/25831)
- ⚡ **Lighter streaming.** Streaming responses now skip re-processing message content that hasn't changed, reducing work on every update. [#26325](https://github.com/open-webui/open-webui/pull/26325), [#26326](https://github.com/open-webui/open-webui/pull/26326)
- ⚡ **Smoother tool-call rendering.** Displaying tool calls now parses their content iteratively, avoiding slowdowns on deeply nested data. [#26146](https://github.com/open-webui/open-webui/pull/26146)
- ⚡ **Hidden tool-call details cost nothing.** When tool-call arguments are collapsed, they are no longer rendered behind the scenes, noticeably speeding up chats with heavy tool use. [Commit](https://github.com/open-webui/open-webui/commit/b7934e918223ec0a9e972accd647a8654e503156), [#26147](https://github.com/open-webui/open-webui/pull/26147)
- ⚡ **Leaner knowledge-file reading for agents.** The built-in tools that let a model read knowledge files now return output in bounded, paginated chunks with a default and a hard cap, instead of potentially returning an entire large file at once, sharply reducing token usage. [Commit](https://github.com/open-webui/open-webui/commit/a285a390c12e27e614d1ba9ffb92d1a0e49e7dfa), [#26139](https://github.com/open-webui/open-webui/issues/26139)
- ⚡ **Lighter, faster file search on large knowledge bases.** Listing and searching files no longer returns each file's full extracted text by default, and content matching is now length-bounded, so these requests are far lighter and searching across very large knowledge bases is dramatically faster. [Commit](https://github.com/open-webui/open-webui/commit/36d08fa2a7), [Commit](https://github.com/open-webui/open-webui/commit/46c1d6591badb6ab567ba1b8fae23475d5da105a), [Commit](https://github.com/open-webui/open-webui/commit/ab84bbf08c5935f1a19044ef581986f83311da8b), [#25774](https://github.com/open-webui/open-webui/pull/25774), [#25741](https://github.com/open-webui/open-webui/issues/25741), [#26145](https://github.com/open-webui/open-webui/pull/26145), [#25867](https://github.com/open-webui/open-webui/issues/25867)
- ⚡ **Faster password hashing and bulk user import.** Password hashing and verification no longer block the server, and importing users from a CSV is now processed in a single batch, keeping large imports and sign-ins responsive. [Commit](https://github.com/open-webui/open-webui/commit/6fdf9b4340), [#25804](https://github.com/open-webui/open-webui/pull/25804), [#25805](https://github.com/open-webui/open-webui/pull/25805)
- ⚡ **Non-blocking model downloads.** Downloading large Ollama models no longer blocks the server on file reads and checksums, keeping it responsive during big downloads. [#25829](https://github.com/open-webui/open-webui/pull/25829)
- ⚡ **Non-blocking uploads and link fetches.** Hashing uploaded files and fetching URLs now run off the main loop, so large uploads and link previews don't hold up other requests. [#25822](https://github.com/open-webui/open-webui/pull/25822)
- ⚡ **More blocking work moved off the main loop.** Additional blocking operations in audio, pipelines, and plugin handling now run in worker threads, keeping the server responsive under load. [#26381](https://github.com/open-webui/open-webui/pull/26381)
- ⚡ **Unreachable backends don't stall model loading.** Loading models and tool servers no longer blocks on backends that are down or slow to respond, so the model list stays responsive when one connection is unreachable. [#26289](https://github.com/open-webui/open-webui/pull/26289)
- ⚡ **Batched streaming updates.** Streaming responses now group small updates of the same type before sending them, reducing overhead during fast token streams and tool-call output. [Commit](https://github.com/open-webui/open-webui/commit/7240517807a8b0097065f7cbbb384d34084f90fd), [#26202](https://github.com/open-webui/open-webui/pull/26202)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Updated translations.** Catalan, Brazilian Portuguese (pt-BR), Irish, German (de-DE), and Spanish (es-ES) translations were updated.
### Fixed
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section — some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
- 🔐 **Knowledge base write access enforced on upload.** Attaching an uploaded file to a knowledge base now requires the same write access as the rest of the knowledge API, so users without write access can no longer add files to a collection by referencing its ID. [#26001](https://github.com/open-webui/open-webui/pull/26001)
- 🗝️ **API key permission enforced on all key endpoints.** Viewing and deleting API keys now respects the API keys permission, matching the protection already applied to key creation. [#25992](https://github.com/open-webui/open-webui/pull/25992)
- 🔊 **Text-to-speech permission enforced on the speech endpoint.** The OpenAI speech proxy now honors the text-to-speech permission, so it can no longer be used by people who are not allowed to use that feature. [#25993](https://github.com/open-webui/open-webui/pull/25993)
- 🎲 **Model access enforced on arena fallback.** Reaching a model indirectly through an arena model on background and task requests now enforces that model's access rules, closing a path that could otherwise bypass them. [#26046](https://github.com/open-webui/open-webui/pull/26046)
- ⏰ **Scheduled automations stop for deactivated accounts.** Scheduled automations now re-check the owner's account status and permissions before each run, so they stop when an account is deactivated or has automations access revoked. [#26047](https://github.com/open-webui/open-webui/pull/26047)
- 🚧 **Heavily encoded paths rejected behind the proxy.** Request paths that remain encoded after repeated decoding are now rejected instead of forwarded, preventing a path traversal that could otherwise slip through. [#26050](https://github.com/open-webui/open-webui/pull/26050)
- 🌐 **Image URL fetches hardened against DNS rebinding.** Fetching user-supplied image URLs now re-checks the destination address at connection time, closing a path that could be used to reach internal addresses behind a public hostname. [#25960](https://github.com/open-webui/open-webui/pull/25960)
- 🛂 **Web fetch blocklist matches on hostname.** The web fetch filter now matches entries against the request's hostname on domain boundaries, so blocked hosts can no longer slip through with an added path and lookalike domains are no longer mistaken for allowed ones. [#25949](https://github.com/open-webui/open-webui/pull/25949)
- 🪪 **MCP connectors request least-privilege scopes.** MCP connectors that register dynamically over OAuth now request only the scopes for the specific resource rather than the authorization server's full catalog. [#25958](https://github.com/open-webui/open-webui/pull/25958)
- 🙈 **Channel member lists no longer expose private data.** Viewing a channel's members now returns only basic profile details, instead of also exposing other members' settings, linked-account data, and personal information. [Commit](https://github.com/open-webui/open-webui/commit/fbcdcf146b99b5002705060a8243eee769108f9e)
- 🛟 **SCIM sync can't demote an admin.** A SCIM provisioning sync that marks a user inactive can no longer strip an existing administrator's role, preventing an instance from being locked out of its own administration. [#25948](https://github.com/open-webui/open-webui/pull/25948)
- 👻 **Collaborative notes reject unauthenticated presence events.** The remaining real-time note-collaboration events now require an authenticated session, so presence and cursors can no longer be spoofed by someone who only knows a note's ID. [#25946](https://github.com/open-webui/open-webui/pull/25946)
- ⏱️ **Login timing no longer reveals which accounts exist.** Sign-in now takes the same amount of time whether or not an account exists, removing a timing difference that could be used to discover valid accounts. [Commit](https://github.com/open-webui/open-webui/commit/993e74912199c66c522f08ec81abe31d76985e39), [Commit](https://github.com/open-webui/open-webui/commit/7b29834d4216e5db70b68f3598fa1ad654d3512b)
- 🔌 **Terminal connections can't be redirected to another user.** Terminal session identifiers are now safely encoded before being passed upstream, closing a way to tamper with the connection's user identity. [#26042](https://github.com/open-webui/open-webui/pull/26042)
- 📡 **Real-time events only reach your own session.** The server now verifies that a real-time event is delivered only to the requesting user's own active session, instead of trusting a client-supplied session identifier. [#25763](https://github.com/open-webui/open-webui/pull/25763)
- 🔓 **Revoked sessions are rejected on real-time connections.** Real-time and terminal WebSocket connections now honor token revocation and expiry, so a signed-out or expired session can no longer keep a live connection open. [Commit](https://github.com/open-webui/open-webui/commit/33b91bd8ae8a100a5a306c91441a7d0b422c4cde), [#25764](https://github.com/open-webui/open-webui/pull/25764), [#25686](https://github.com/open-webui/open-webui/pull/25686)
- 🕳️ **Another DNS-rebinding gap closed in URL fetching.** Fetching a URL's content now re-checks the destination address at connection time, closing another path that could reach internal addresses behind a public hostname. [#25775](https://github.com/open-webui/open-webui/pull/25775)
- 🗣️ **Azure speech input is escaped.** Voice and language values are now escaped when building Azure text-to-speech requests, preventing malformed or injected markup. [#25776](https://github.com/open-webui/open-webui/pull/25776)
- ⚙️ **Interface settings update respects its permission.** Saving interface settings now enforces the interface permission, so users without it can no longer change those settings through the API. [#25996](https://github.com/open-webui/open-webui/pull/25996)
- 🗄️ **Unknown knowledge collections are denied by default.** Retrieval now rejects unknown or unscoped collection names by default, closing a legacy path that could be used to reach collections outside the normal access checks. [Commit](https://github.com/open-webui/open-webui/commit/d99ac7d3f83b25161ca775229150c8f7c74cceee)
- 🙈 **Error responses no longer leak internals.** Server error responses now return sanitized messages instead of raw exception text, so internal details aren't exposed to signed-in users. [Commit](https://github.com/open-webui/open-webui/commit/ee5de69e374aabf5631da18a5bbc1c285ee6f7a1), [Commit](https://github.com/open-webui/open-webui/commit/0cc331d1c60341bb06b78ceeecfb2db86179c93e), [Commit](https://github.com/open-webui/open-webui/commit/396d9ac18193d43e40fb9d068075d4b780e971d7), [Commit](https://github.com/open-webui/open-webui/commit/0883638027a9b3cb7c9851f031c4f5fc1af1f25d), [#26375](https://github.com/open-webui/open-webui/pull/26375), [#26374](https://github.com/open-webui/open-webui/issues/26374)
- 📏 **Upload size limit enforced on the server.** The maximum upload size is now enforced server-side, so it can't be bypassed by a client that ignores the limit. [Commit](https://github.com/open-webui/open-webui/commit/f8ec63203c4408c46bb06698ae624d17b01b9301), [Commit](https://github.com/open-webui/open-webui/commit/d3676b4f71bfdbaf4e4d76943c51117e18932ccf), [#25869](https://github.com/open-webui/open-webui/pull/25869)
- 🖼️ **OAuth profile pictures are validated.** Profile picture URLs from OAuth providers are now validated and their type checked when stored, preventing unsafe image sources. [Commit](https://github.com/open-webui/open-webui/commit/eb53281c9acb3660e09554a8dbde0a0b42646b70), [#24548](https://github.com/open-webui/open-webui/pull/24548)
- 📦 **Security updates to frontend dependencies.** Several frontend dependencies were updated to patch known security vulnerabilities. [#26281](https://github.com/open-webui/open-webui/pull/26281)
- 🤝 **Chat sharing respects the user-sharing permission.** The share-chat dialog now hides the option to share with specific users from people who lack that permission, matching the access rules enforced elsewhere. [#25915](https://github.com/open-webui/open-webui/pull/25915)
- 📤 **Chat export respects its permission everywhere.** Every chat export menu now checks the export permission, so users without it can no longer export chats through one of the dropdown menus. [#25914](https://github.com/open-webui/open-webui/pull/25914)
- 📂 **File write access requires real ownership.** Editing or deleting a file through a knowledge base or workspace model now requires that the object's owner actually owns the file, so a read-only file can no longer gain write access by being referenced from an object you control. [#26032](https://github.com/open-webui/open-webui/pull/26032)
- 🖌️ **Image edit endpoint enforces permission.** The image-edit endpoint now checks the image-edit switch and the image-generation permission, matching image generation, so it can't be called by users who lack access. [#26009](https://github.com/open-webui/open-webui/pull/26009)
- 📁 **Folder permission enforced on all folder actions.** Every folder operation now checks the folders permission, so the setting is respected consistently instead of only when listing folders. [Commit](https://github.com/open-webui/open-webui/commit/19a176fd36bea15c49d7f2d1539b4832e57a8bc2)
- 🧩 **Code Execution settings collapse when off.** The Code Execution settings section now collapses when the toggle is disabled, keeping the settings page tidy. [#25970](https://github.com/open-webui/open-webui/pull/25970)
- 📅 **German date format in Notes.** Dates in the Notes view now display correctly for German, where they previously failed to render. [#25985](https://github.com/open-webui/open-webui/pull/25985)
- 🎙️ **ElevenLabs speech keeps working when voices can't load.** Text-to-speech through ElevenLabs no longer fails when the available-voice list can't be fetched, instead of rejecting every voice. [Commit](https://github.com/open-webui/open-webui/commit/bb1419328b11b801b4c939dfc112700ba6f6fdab), [#26075](https://github.com/open-webui/open-webui/issues/26075)
- 🪟 **Default Permissions modal resets on close.** Closing the Default Permissions dialog without saving now discards unsaved edits instead of keeping them around the next time you open it. [Commit](https://github.com/open-webui/open-webui/commit/78a5015846a9e55ff2bc9d6cc98f880437abe8ed)
- 👯 **Side-by-side chat with the same model.** Running two panes with the same model no longer leaves one pane stuck waiting or showing the other pane's reply after a reload, since each pane's messages are now tracked separately. [Commit](https://github.com/open-webui/open-webui/commit/56ae99e96a845289b5787d2dd26a3d828f2295e7), [#25982](https://github.com/open-webui/open-webui/issues/25982)
- 💾 **Model edits no longer lost when changing access.** Adjusting a model's access no longer auto-saves on its own and discards your other unsaved changes to that model. [#26004](https://github.com/open-webui/open-webui/pull/26004)
- 🔧 **Parallel tool calls over the Anthropic-compatible API.** External Anthropic-compatible clients calling Open WebUI's messages endpoint now receive tool calls reliably when a model issues several at once or returns them in its final message. [Commit](https://github.com/open-webui/open-webui/commit/4210cae68e30173d7902582d32128dd699d5628a), [#25963](https://github.com/open-webui/open-webui/pull/25963), [#25964](https://github.com/open-webui/open-webui/discussions/25964)
- 🗃️ **Prompt caching preserved over the Anthropic-compatible API.** Requests through the Anthropic-compatible API now keep their prompt-caching markers instead of having them stripped, so clients that rely on caching work as intended. [Commit](https://github.com/open-webui/open-webui/commit/caedcbae4988ef59ea7052b2a3198e2da4b5291a), [#25998](https://github.com/open-webui/open-webui/pull/25998), [#25964](https://github.com/open-webui/open-webui/discussions/25964)
- 🔁 **Fewer redundant data loads.** Several views no longer fire duplicate background fetches at once, avoiding occasional glitches from overlapping requests. [#25943](https://github.com/open-webui/open-webui/pull/25943), [#25942](https://github.com/open-webui/open-webui/pull/25942), [#25934](https://github.com/open-webui/open-webui/pull/25934), [#25935](https://github.com/open-webui/open-webui/pull/25935), [#25838](https://github.com/open-webui/open-webui/pull/25838), [Commit](https://github.com/open-webui/open-webui/commit/e8d55c0a8beac9de0b2a0fe90f0bc0f9b64c1c1f)
- 🔎 **Steadier search boxes across admin and workspace.** Search fields for users, knowledge, prompts, tools, and similar lists now run only as you type and reset to the first page correctly, instead of occasionally re-searching on their own. [Commit](https://github.com/open-webui/open-webui/commit/fc9c2ea1915accd1f6edca467e965283dff71cd7), [#25938](https://github.com/open-webui/open-webui/pull/25938)
- 📊 **Admin feedback list loads again on PostgreSQL.** The admin feedback list no longer fails to load on PostgreSQL setups, where it previously returned a server error. [Commit](https://github.com/open-webui/open-webui/commit/7ee75a0c04a31528954903e88c9213d5fbb31aa7), [#25953](https://github.com/open-webui/open-webui/issues/25953)
- 🗂️ **Deleting nested folders checks chats correctly.** Deleting a folder that contains subfolders now accounts for the chats inside those subfolders when applying the delete-permission check, instead of only the top-level folder's chats. [Commit](https://github.com/open-webui/open-webui/commit/232421f40b84590e6d6fdecab4e43274aac37add), [#25920](https://github.com/open-webui/open-webui/issues/25920)
- 🖱️ **Dragging chats into folders is more reliable.** Dragging a chat into a folder no longer throws an error in cases where the chat couldn't be resolved. [#25928](https://github.com/open-webui/open-webui/pull/25928)
- 🛠️ **Workspace menu shows for the skills permission.** Users who only have the skills permission now see the Workspace entry in their menu, which previously appeared only for other workspace permissions. [#25925](https://github.com/open-webui/open-webui/pull/25925)
- 🧠 **Admins can always reach memories.** Administrators can now use the memories endpoints regardless of the memories permission toggle, matching how admin access works for other features. [#25924](https://github.com/open-webui/open-webui/pull/25924)
- 🖼️ **Image settings page survives a config load failure.** The admin image settings page no longer crashes when its configuration fails to load, showing the page instead. [#25933](https://github.com/open-webui/open-webui/pull/25933)
- 🧵 **Code blocks render in channel threads.** Code blocks now display correctly in a channel's thread view, where duplicated message identifiers previously broke their rendering. [Commit](https://github.com/open-webui/open-webui/commit/7d1f9415807a47e0da4f862327e9802a3b839753), [#25917](https://github.com/open-webui/open-webui/pull/25917)
- 🔵 **No more false unread badges on chats.** Chats no longer show an unread indicator after automatic changes like title generation or pinning, archiving, and moving them between folders, and newly created chats are marked read correctly so they don't appear unread after a refresh. [#25912](https://github.com/open-webui/open-webui/pull/25912), [#25782](https://github.com/open-webui/open-webui/pull/25782), [#25108](https://github.com/open-webui/open-webui/issues/25108)
- 📌 **Pinned notes stay in sync.** Pinning, unpinning, or deleting a note now updates the sidebar's pinned list consistently, instead of showing a stale pin state. [#25918](https://github.com/open-webui/open-webui/pull/25918), [#25640](https://github.com/open-webui/open-webui/pull/25640)
- 📅 **All-day calendar events keep their date.** Saving an all-day calendar event no longer shifts it by a day for users in certain time zones. [#25864](https://github.com/open-webui/open-webui/pull/25864)
- 🧷 **Damaged chat history recovers more reliably.** When a chat's current position is missing or points at a malformed message, Open WebUI now repairs it from the latest valid message — on both the client and the server — instead of risking a broken history view. [Commit](https://github.com/open-webui/open-webui/commit/2308b59f135e4c2da11eabdf2306e55a5dd4e9fb), [Commit](https://github.com/open-webui/open-webui/commit/a146e17bdcaf94fee3a98aa36b4f401e1f06c1d4), [#26298](https://github.com/open-webui/open-webui/pull/26298), [#26258](https://github.com/open-webui/open-webui/pull/26258), [#26257](https://github.com/open-webui/open-webui/issues/26257)
- 💾 **Saving a chat no longer drops messages.** Chat updates are now merged with the existing history on the server, with explicit tracking of deleted messages, instead of overwriting it, preventing message loss from concurrent or partial saves. [Commit](https://github.com/open-webui/open-webui/commit/22a44e67a8ba781feb8f2a267fed0c40213d8432), [Commit](https://github.com/open-webui/open-webui/commit/3319b6410e1b600b7a885a5fb78573e9a2061c22), [Commit](https://github.com/open-webui/open-webui/commit/24b8619f64731788ac38813768abbb64405effa4), [#25657](https://github.com/open-webui/open-webui/pull/25657)
- 📺 **Channel message updates stay in their channel.** Streaming updates to a channel message are now skipped if the message no longer exists or belongs to a different channel, preventing stray updates. [Commit](https://github.com/open-webui/open-webui/commit/ac3449cac91e62b08a7c28e54fcd044d14dea791)
- 📌 **Pinned channel messages update for everyone.** Pinning or unpinning a channel message now updates live for all members and works from thread views, instead of only changing for the person who pinned it. [Commit](https://github.com/open-webui/open-webui/commit/7ea7680f563da30b121258e5a7d7123185c4da2a)
- 📄 **Mistral OCR uploads work again.** Document OCR through Mistral has been repaired after an upstream library change broke its file uploads. [#25779](https://github.com/open-webui/open-webui/pull/25779)
- 🗂️ **Chroma collection detection fixed.** Open WebUI now correctly detects existing Chroma collections, fixing a case where it always reported them as missing. [#25780](https://github.com/open-webui/open-webui/pull/25780)
- 📊 **Vega-Lite charts render reliably.** Vega-Lite charts in chat are now detected by their code block language tag, so they render correctly. [#25843](https://github.com/open-webui/open-webui/pull/25843)
- 🏷️ **Long chat tag lists scroll.** The tags section in the chat menu now scrolls instead of overflowing when a chat has many tags. [#26031](https://github.com/open-webui/open-webui/pull/26031)
- ⌨️ **Enter key shows correctly on iOS.** The Enter key symbol in the keyboard shortcuts list no longer renders as an emoji on iOS. [#26173](https://github.com/open-webui/open-webui/pull/26173)
- 🔗 **Whitespace in names no longer breaks MCP connections.** User name and info headers are now trimmed before being forwarded, fixing MCP connection failures when a display name contained leading or trailing whitespace. [#26182](https://github.com/open-webui/open-webui/pull/26182), [#26181](https://github.com/open-webui/open-webui/issues/26181)
- 🈳 **Search no longer fires mid-composition.** Typing in search with an input method editor (such as Japanese, Chinese, or Korean) no longer triggers a search when you press Enter to confirm a composition. [#26238](https://github.com/open-webui/open-webui/pull/26238), [#26285](https://github.com/open-webui/open-webui/pull/26285), [#26172](https://github.com/open-webui/open-webui/issues/26172)
- 🧰 **Valves icon stays visible.** The icon for configuring valves no longer disappears, so user-configurable tool and function settings remain reachable. [#26256](https://github.com/open-webui/open-webui/pull/26256)
- 🎛️ **Chat controls persist across navigation.** Edits to chat controls are now kept when navigating between chats, and reverting a control to the chat's saved value persists correctly, instead of being lost. [#26336](https://github.com/open-webui/open-webui/pull/26336), [#25793](https://github.com/open-webui/open-webui/pull/25793)
- 🔍 **Chat search tool handles empty queries.** The built-in chat search tool no longer crashes when called with an empty query. [Commit](https://github.com/open-webui/open-webui/commit/b854eb09b13216f914ce5fd07ab717b8f752882b), [#26310](https://github.com/open-webui/open-webui/issues/26310)
- 📑 **More robust MinerU document processing.** Document processing through MinerU now handles its ZIP results more safely, including very large outputs. [Commit](https://github.com/open-webui/open-webui/commit/23d03d6aaebcced6c1e39e98dfff76ab73df8804), [#26263](https://github.com/open-webui/open-webui/pull/26263)
- ⏰ **Scheduled automations with session-auth tools work.** Automations that use session-authenticated tools or terminals now authenticate correctly when running on a schedule, instead of failing. [Commit](https://github.com/open-webui/open-webui/commit/5b1c42e81a3ef3ad5ce5852dbf84020cb5e2498c), [#26247](https://github.com/open-webui/open-webui/pull/26247), [#26137](https://github.com/open-webui/open-webui/issues/26137)
- 📝 **Model system prompt preserved with knowledge.** A model's system prompt is no longer dropped when knowledge retrieval runs with native tool calling. [Commit](https://github.com/open-webui/open-webui/commit/cfb49c4c181a96d5df07fbfacd819639baef0bab), [#26217](https://github.com/open-webui/open-webui/pull/26217)
- 🔑 **Expired sessions return you to sign-in.** When a request fails because your session has expired, Open WebUI now redirects you to the sign-in page instead of leaving you on a broken view. [Commit](https://github.com/open-webui/open-webui/commit/5922727402593900758d84004f950071c701f6de), [#26237](https://github.com/open-webui/open-webui/pull/26237)
- 🎯 **Ejecting a workspace model unloads the right model.** Unloading a workspace model now resolves to its underlying base model, so the correct model is freed from memory. [Commit](https://github.com/open-webui/open-webui/commit/464e703e4716812d015966582152ddd8a2c71572), [#26269](https://github.com/open-webui/open-webui/pull/26269)
- 🔄 **Edited models refresh in the admin list.** After editing a model in the admin settings, the models list now updates right away instead of needing a manual reload. [Commit](https://github.com/open-webui/open-webui/commit/b34d6c836ee43d0e9721fa4fd6457d934e3e2a17)
- 🗂️ **Workspace model bulk actions and search work across pages.** Bulk actions on workspace models now apply across all of them, and search results paginate correctly. [#26274](https://github.com/open-webui/open-webui/pull/26274)
- 🧩 **MCP resource results come through.** Tool results that return resource content — including binary blobs and URI references — are no longer silently dropped, and image results are attached as files. [#25260](https://github.com/open-webui/open-webui/pull/25260), [#24038](https://github.com/open-webui/open-webui/issues/24038), [Commit](https://github.com/open-webui/open-webui/commit/783205a965c556815fae84b64d74f26a2e5e5729)
- 🔗 **Broader MCP server compatibility for OAuth.** Open WebUI now discovers an MCP server's protected resource metadata even when the server doesn't advertise it, and recognizes more OAuth preflight variations, so more MCP servers connect. [#25980](https://github.com/open-webui/open-webui/pull/25980), [#25954](https://github.com/open-webui/open-webui/issues/25954), [Commit](https://github.com/open-webui/open-webui/commit/45fea34bd0c8ce54b0822499c40e3e6964220354), [#26068](https://github.com/open-webui/open-webui/pull/26068)
- 📤 **Clearer upload error messages.** Failed uploads now show a readable explanation instead of an opaque error stub. [#25961](https://github.com/open-webui/open-webui/pull/25961)
- 📋 **Cloned prompts get a proper title.** Cloning a prompt now adds the clone suffix to the correct field, so the duplicate is named as expected. [#25800](https://github.com/open-webui/open-webui/pull/25800)
- 📐 **Long default group names don't overflow.** A long default group name no longer overflows its row in the admin authentication settings. [#25685](https://github.com/open-webui/open-webui/pull/25685)
- 🖐️ **Sidebar drags don't trigger uploads.** Dragging a chat item in the sidebar no longer shows the file-upload overlay. [#25675](https://github.com/open-webui/open-webui/pull/25675)
- 🔁 **Recovers from a stuck streaming response.** If the signal that a response finished is missed — for example after a mobile app is backgrounded mid-stream — Open WebUI now recovers the chat instead of leaving it stuck in a streaming state. [Commit](https://github.com/open-webui/open-webui/commit/aa851d93c63e7da6e94292d0b7586674339d47e5), [Commit](https://github.com/open-webui/open-webui/commit/edf2c6c8f76e7f6a5917e991f371a604adc34c5f), [Commit](https://github.com/open-webui/open-webui/commit/2856def6c05b2fb8c55b4e7170f05db0c4f956f1), [#26320](https://github.com/open-webui/open-webui/pull/26320), [#26315](https://github.com/open-webui/open-webui/issues/26315)
- 🧠 **Model skills load on demand instead of filling the prompt.** A model's attached skills are now presented to the model as a manifest it can load when needed, rather than having their full content inserted into the system prompt; skills you mention inline still get their content included directly. [Commit](https://github.com/open-webui/open-webui/commit/e6d35fc4cca4f4b1e5cad97d7b7e3089ef832018), [Commit](https://github.com/open-webui/open-webui/commit/44b9463498085741669e6f5d92e21b5ecc5fd795), [#25592](https://github.com/open-webui/open-webui/issues/25592), [#25599](https://github.com/open-webui/open-webui/pull/25599)
- 🗂️ **Empty metadata no longer breaks Chroma indexing.** Document metadata with empty values is now filtered out before indexing, fixing a case that could fail on Chroma. [Commit](https://github.com/open-webui/open-webui/commit/118549caf3), [#26342](https://github.com/open-webui/open-webui/pull/26342), [#26339](https://github.com/open-webui/open-webui/issues/26339)
- 🔁 **Updating a knowledge file won't break the knowledge base.** When a file's content is updated, its new embeddings are now added before the old ones are removed, so a failed reindex leaves the knowledge base intact and usable instead of empty. [Commit](https://github.com/open-webui/open-webui/commit/248315de14d4537e0f2ec3f94dee8a7334cad248), [#23789](https://github.com/open-webui/open-webui/pull/23789), [#23787](https://github.com/open-webui/open-webui/issues/23787)
- 🔤 **Documents with special tokens index correctly.** Measuring chunk sizes no longer fails when a document contains text that looks like a special token. [#26210](https://github.com/open-webui/open-webui/pull/26210)
- 📝 **Note file attachments stay in sync.** Updating the files attached to a note now keeps the editor and saved note in sync. [Commit](https://github.com/open-webui/open-webui/commit/5055fb85aa8c8d5ef785daea7438498e36ddf33f)
- 📱 **Better banner layout on mobile.** Notification banners now lay out correctly on small screens. [Commit](https://github.com/open-webui/open-webui/commit/4ed45ce84394c435405f93d03f07dabd797cdec3), [#24912](https://github.com/open-webui/open-webui/pull/24912)
- 📂 **Knowledge file listing includes attached files.** Listing files through the knowledge tools now also shows files attached directly to a model, not only those inside a knowledge base, fixing cases where listing returned no results for a model with a single attached file. [Commit](https://github.com/open-webui/open-webui/commit/40b655e99e2c6dd802654ec0cdac38a4bcda08b3), [#26301](https://github.com/open-webui/open-webui/issues/26301)
- 🏷️ **Chat titles generate after long first responses.** A new chat now gets its title even when the first response takes a long time, such as one with extensive reasoning or many tool calls, instead of staying "New Chat". [Commit](https://github.com/open-webui/open-webui/commit/754787f43dffad3dce2c90e4fd0417b1f9dbb3c0), [#26240](https://github.com/open-webui/open-webui/issues/26240)
- 🔌 **Cancelling an MCP request no longer errors.** Stopping a response that was using MCP tools now shuts the connection down cleanly instead of surfacing a server error. [Commit](https://github.com/open-webui/open-webui/commit/ff5cec43bd360829cfdcc6a5253d1ba63f236b7f)
- 🧠 **Reasoning details preserved across turns.** Models that return structured or encrypted reasoning data, such as Gemini, no longer have their assistant message split mid-stream, keeping reasoning continuity across turns. [Commit](https://github.com/open-webui/open-webui/commit/75db531c1238af113bb2b211882713e5e2f459cf), [#23852](https://github.com/open-webui/open-webui/pull/23852)
- 📡 **Error messages show for non-standard streaming responses.** Providers that send errors over non-standard server-sent events now surface a readable error instead of nothing. [#23228](https://github.com/open-webui/open-webui/pull/23228)
- 🔑 **Whitespace in terminal server keys no longer breaks auth.** Terminal server API keys are now trimmed before use, so a key with stray leading or trailing whitespace still authenticates. [Commit](https://github.com/open-webui/open-webui/commit/fe3300bd6581aa469c2cdf757700ecc65a200df4), [Commit](https://github.com/open-webui/open-webui/commit/d6cda4a04b2e3a48855fc91abb2376cfd3a0378d)
- 🔥 **One bad URL no longer fails Firecrawl scraping.** When fetching multiple pages through Firecrawl, a single failing URL is now skipped instead of aborting the whole batch, and rate limits are respected between requests. [Commit](https://github.com/open-webui/open-webui/commit/6f8221df58b17334233ac6bfe069b8f837f677d6), [#24183](https://github.com/open-webui/open-webui/pull/24183)
- 📱 **Usable chat input on mobile with many tools.** When skills, tools, terminal, web search, and image generation buttons fill the chat input, the row of buttons now scrolls horizontally while the menu, voice, and send controls stay reachable, instead of pushing them off-screen. [Commit](https://github.com/open-webui/open-webui/commit/6f8221df58b17334233ac6bfe069b8f837f677d6), [#26142](https://github.com/open-webui/open-webui/issues/26142)
- 👤 **Owner avatars only show on shared folders.** Chat owner avatars in a folder's chat list now appear only when the folder is actually shared, instead of showing whenever owner information happened to be present. [Commit](https://github.com/open-webui/open-webui/commit/9802b0d13563b3535b86a350bab000d84686b1e9)
- 📜 **No stray scrollbar on the About page.** Extra spacing that caused an unnecessary scrollbar on the About settings page has been removed. [#25802](https://github.com/open-webui/open-webui/pull/25802)
- 🚪 **Sign out works from the Account Pending page.** Signing out while your account is pending now goes through the proper sign-out flow, so single sign-on sessions are ended and you are no longer left stuck on the pending screen. [#25681](https://github.com/open-webui/open-webui/pull/25681), [#25644](https://github.com/open-webui/open-webui/issues/25644)
- 🔢 **Built-in tools accept numeric arguments.** Built-in tools no longer crash when a model passes a number or a string where a specific scalar type is expected; values are now coerced to the declared type. [Commit](https://github.com/open-webui/open-webui/commit/c4688b958d7f7929f5f4303493ca311c2c121683), [#25638](https://github.com/open-webui/open-webui/pull/25638), [#25731](https://github.com/open-webui/open-webui/pull/25731), [#25641](https://github.com/open-webui/open-webui/issues/25641)
- ⏱️ **MinerU timeout saves.** The MinerU API timeout can now be saved from the admin settings, accepting a numeric value. [Commit](https://github.com/open-webui/open-webui/commit/3fd0384ffcd0eddd6f4c688475f8ad5d3b4de510), [#25604](https://github.com/open-webui/open-webui/pull/25604), [#25603](https://github.com/open-webui/open-webui/issues/25603)
- 🔧 **Background completion no longer clears active tasks.** Finishing a chat in the background no longer wipes the set of active tasks, fixing a case where ongoing task indicators could be lost. [Commit](https://github.com/open-webui/open-webui/commit/388f62f8a002b789887d016892a1bf152c9d90af), [#25217](https://github.com/open-webui/open-webui/issues/25217)
- 👁️ **Workspace base model selector respects visibility.** The base model selector in the workspace now hides models you don't have access to, matching their visibility settings. [#25668](https://github.com/open-webui/open-webui/pull/25668)
- 🧵 **Channel threads bind to the right channel.** A channel thread's parent and replies are now tied to the channel in the URL, preventing mismatches when switching channels. [#25766](https://github.com/open-webui/open-webui/pull/25766)
- 🗑️ **Unsharing cleans up orphaned rows.** Unsharing a chat now handles leftover shared-chat records, avoiding stale entries. [#25632](https://github.com/open-webui/open-webui/pull/25632)
- 🔎 **Web search results reach the model with retrieval on.** Web search results are now passed to the model even when embedding and retrieval are enabled, instead of being left out. [#25600](https://github.com/open-webui/open-webui/pull/25600)
- 🔢 **Group count follows search.** The groups count now reflects the filtered search results instead of the full list. [#25689](https://github.com/open-webui/open-webui/pull/25689)
- ␣ **Space key works when renaming.** Pressing space while renaming a file or folder no longer opens it, so spaces can be typed in names. [#25627](https://github.com/open-webui/open-webui/pull/25627)
- 🩹 **Missing local embedding model no longer blocks startup.** A missing local embedding model now surfaces as a deferred error instead of preventing the server from starting. [#25683](https://github.com/open-webui/open-webui/pull/25683)
- 🔤 **Consistent settings label capitalization.** Toggle labels in settings now use consistent title casing. [#25765](https://github.com/open-webui/open-webui/pull/25765)
- ♿ **Better screen-reader labels on toggles.** Integration and switch toggles now expose proper accessibility labels and pressed state for screen readers. [#25258](https://github.com/open-webui/open-webui/pull/25258), [#25230](https://github.com/open-webui/open-webui/pull/25230)
- 📜 **Long dropdowns scroll.** Dropdown selects now scroll when their list is long, so all options stay reachable. [Commit](https://github.com/open-webui/open-webui/commit/4bc463072185d0d7c1c9218cd4487501090eccfe), [#25608](https://github.com/open-webui/open-webui/pull/25608)
- 🔽 **Collapsible sections don't misfire on load.** Collapsible sections no longer trigger their change action when first rendered, avoiding unintended toggles on page load. [Commit](https://github.com/open-webui/open-webui/commit/c93d4f04aad1b0d4f8a8bda7ac403b2c8ee35f38), [#25229](https://github.com/open-webui/open-webui/pull/25229)
- ➗ **Large math expressions no longer crash rendering.** Parsing math delimiters no longer overflows on very large or deeply nested input, so messages with heavy math render instead of failing. [#25845](https://github.com/open-webui/open-webui/pull/25845)
- 🗄️ **Oversized chunks no longer break Milvus indexing.** Overly long text chunks are now trimmed before being sent to Milvus, so a single large chunk can no longer fail the whole batch and leave a file with no embeddings. [#25857](https://github.com/open-webui/open-webui/pull/25857), [#25858](https://github.com/open-webui/open-webui/pull/25858)
- 📝 **Code editor stays open when empty.** The code editor drawer no longer collapses when its content is empty. [#25855](https://github.com/open-webui/open-webui/pull/25855)
- 💽 **Settings no longer lost after a restart.** Admin configuration is now stored more reliably, fixing cases where external connections and model parameters could be lost after restarting the server. [Commit](https://github.com/open-webui/open-webui/commit/5cdcdbaeec9fc8156721c38c33ec37956962871c), [Commit](https://github.com/open-webui/open-webui/commit/21f9e5295bf484169d72f4538f7c926b5519723c), [Commit](https://github.com/open-webui/open-webui/commit/8958b64b5a7e96cd8c2260571b54324ca3bfe127), [#24743](https://github.com/open-webui/open-webui/issues/24743), [#25911](https://github.com/open-webui/open-webui/pull/25911), [#25959](https://github.com/open-webui/open-webui/pull/25959)
- 📜 **Visible chat scrollbar.** The chat area now shows a scrollbar, making it easier to scroll through long responses. [Commit](https://github.com/open-webui/open-webui/commit/d56e1cb0b9), [#25833](https://github.com/open-webui/open-webui/issues/25833)
- 🎚️ **Default model parameters apply to requests.** Default model parameters are now applied to outbound requests, so settings like temperature and the context window take effect as configured. [Commit](https://github.com/open-webui/open-webui/commit/cd6cc39c6d), [Commit](https://github.com/open-webui/open-webui/commit/19db873603215773f9a64e03785a1a076dc6c8a8), [#24930](https://github.com/open-webui/open-webui/issues/24930), [#26209](https://github.com/open-webui/open-webui/issues/26209)
- 🟢 **Ollama loaded-model indicator restored.** The indicator showing which Ollama model is loaded in VRAM works again after recent changes. [#25586](https://github.com/open-webui/open-webui/issues/25586), [#25732](https://github.com/open-webui/open-webui/issues/25732)
- 🪪 **Static MCP connectors recover missing OAuth details.** MCP connectors configured with static OAuth credentials now fill in a missing scope or resource from the server's published metadata, so they connect correctly instead of failing when those values were left out. [Commit](https://github.com/open-webui/open-webui/commit/88901bfa041ddcceab1cd4a97f08f0b43835eb05), [#25898](https://github.com/open-webui/open-webui/issues/25898)
- 📊 **Token usage and cost stats no longer wiped by background tasks.** A response's token usage and cost are now preserved when background tasks like title, tag, and follow-up generation run on the same chat, instead of being overwritten. [Commit](https://github.com/open-webui/open-webui/commit/95391221dfabbfcd9090ab472b1c02a4c75c0387)
- 🔗 **Model share link updated.** Sharing a model now opens the current community post page, fixing the link that pointed at the old endpoint. [#25801](https://github.com/open-webui/open-webui/pull/25801)
### Changed
- ⚠️ **Database Migrations**: This update contains database migrations. Please be sure to back up your database before updating, as downgrading after the migration is not supported.
- 🔔 **System events now fire automatically.** With the new event system, Open WebUI emits events for activity like startup, sign-ins, and configuration changes, so any webhook you already have configured may begin receiving calls for these newly emitted events after upgrading. Review your event and webhook settings after updating so you only receive the events you want. [Commit](https://github.com/open-webui/open-webui/commit/b5c43968db0ea1556b228d143ae5946dc4e944ba)
- 🔀 **Native tool calling is now the default.** Every chat and model that had not explicitly chosen a tool-calling mode now runs Native, which relies on a model's built-in tool support, while the old behavior has been renamed "Legacy" and made the explicit opt-out; if your models depend on the previous approach you must switch them back to "Legacy" per chat, per model, or globally in your default model parameters to preserve their behavior. [Commit](https://github.com/open-webui/open-webui/commit/b1d40f340921c27eb9a965b9feeb2563856e25e2)
- 🗂️ **Authentication settings moved to their own page.** LDAP, OAuth, and related authentication settings have moved out of the General settings page into a dedicated Authentication page in the admin panel. [Commit](https://github.com/open-webui/open-webui/commit/5cdcdbaeec9fc8156721c38c33ec37956962871c)
- 🎓 **Several features are no longer beta.** Memories, Notes, Channels, and High Contrast Mode have graduated out of beta and no longer carry a beta label. [Commit](https://github.com/open-webui/open-webui/commit/7b55a63fc7ee323e9114713ce1d2f3f688aa37e6)
- 🔧 **Local web fetch setting renamed.** The "ENABLE_RAG_LOCAL_WEB_FETCH" environment variable is now "ENABLE_LOCAL_WEB_FETCH", reflecting that it applies beyond retrieval; the old name still works as a deprecated alias. [Commit](https://github.com/open-webui/open-webui/commit/e3ba6984534898695b47ee4fc3d6b746e2865abc)
- 🔧 **You.com search key renamed.** You.com web search now prefers the "YDC_API_KEY" environment variable, with the previous "YOUCOM_API_KEY" still accepted as a fallback. [Commit](https://github.com/open-webui/open-webui/commit/df634bb64f5043b0292e43c69bd1d31676c89328), [#26316](https://github.com/open-webui/open-webui/pull/26316)
- 🧪 **Client-side Python now runs sandboxed.** Client-side Python (Pyodide) now runs in a sandboxed, opaque-origin iframe by default, isolating executed code from your session, cookies, local storage, and the app's own endpoints, while full Python, JavaScript, and external network access keep working. Code that relied on reaching same-origin Open WebUI endpoints from Pyodide will no longer be able to, and Pyodide is now marked legacy in the admin Code Execution settings. [Commit](https://github.com/open-webui/open-webui/commit/516051304e1b1f250c34438746ade673a79bd40c), [Commit](https://github.com/open-webui/open-webui/commit/c7be66626fd10c75ec35f662a709129ba1b020ec), [Commit](https://github.com/open-webui/open-webui/commit/62ae2069183109d878d72b9444a0e7c4f6c66caa), [Commit](https://github.com/open-webui/open-webui/commit/518702caae5a6484e71aa79e8ab908ec398290a7), [Commit](https://github.com/open-webui/open-webui/commit/03a8363583b7e0e04760d49f1e8d28dbbfefee4d)
## [0.9.6] - 2026-06-01
### Added
- 📦 **Official knowledge base sync tool.** A new companion tool from Open WebUI, oikb, keeps a knowledge base in sync with a local directory, GitHub repo, S3 bucket, Confluence space, or any of more than 40 other sources, uploading only new and changed files using the incremental sync support added in this release. [oikb](https://github.com/open-webui/oikb)
- 📂 **Smart directory sync for knowledge bases.** Local directories can now be synced into a knowledge base in one action: file checksums are compared against what's already stored, and only added or modified files are uploaded while removed files and orphaned subdirectories are cleaned up, with the directory structure mirrored automatically and per-file progress shown throughout. [#19190](https://github.com/open-webui/open-webui/issues/19190), [#19394](https://github.com/open-webui/open-webui/issues/19394), [Commit](https://github.com/open-webui/open-webui/commit/60c9db1cb81d021589cb49bee8744a799b51211f), [Commit](https://github.com/open-webui/open-webui/commit/73bdf86766d3f44467cdec436786fe089481baf3), [Commit](https://github.com/open-webui/open-webui/commit/9835b3f1dd7aa9c92ff08a634a0f97cc2a046e42), [Commit](https://github.com/open-webui/open-webui/commit/97252fa609440573251d8e75090f347ed1e51e1d), [Commit](https://github.com/open-webui/open-webui/commit/8f2d346e10c47b57bf6b5a6aa02d453488a88b89), [Commit](https://github.com/open-webui/open-webui/commit/1527eb6e01d441225c979d12d78b7625edf1086f)
- 🗂️ **Knowledge base folders.** Files inside a knowledge base can now be organized into nested folders, with breadcrumb navigation that makes it much easier to manage and find content in large collections. [Commit](https://github.com/open-webui/open-webui/commit/c2cbc47ca76ebfa21e1d36279dbd859283cbbfb1), [Commit](https://github.com/open-webui/open-webui/commit/ab0ee858b73738c5b77d1d455e66e66bc61df1c4), [Commit](https://github.com/open-webui/open-webui/commit/2ad327a4dce64a8eedf5eb41c73b8520344fdd82), [Commit](https://github.com/open-webui/open-webui/commit/171150c1e12b5713f77a5ea0dd19c6581c7bef2e), [Commit](https://github.com/open-webui/open-webui/commit/e7d2ddbb1d14c03a52797751d24a98132aac0cd8), [Commit](https://github.com/open-webui/open-webui/commit/32a417bbf66ed28a5ca8a759ad3a87b2a2aa358a)
- 🧰 **Filesystem tool for knowledge bases.** A new built-in tool, enabled via the "ENABLE_KB_EXEC" environment variable, lets AI models browse and search knowledge base contents using familiar filesystem commands such as 'ls', 'cat', 'grep', 'find', 'head', 'tail', and 'sed', including pipes between them. [Commit](https://github.com/open-webui/open-webui/commit/5b125c24d4eae925d3287efa626595bab29d5c33), [Commit](https://github.com/open-webui/open-webui/commit/ecec86dd32aa396dd5a383e9be5b0f16c4346c48), [Commit](https://github.com/open-webui/open-webui/commit/9ef579ce4be5b82f9281b085a621fed5b04e0d26), [Commit](https://github.com/open-webui/open-webui/commit/2f642754ac6b8c430228a7fc289357a6c6652235), [Commit](https://github.com/open-webui/open-webui/commit/3b00e5721a60518ea317c4fd61a6d0d182961a2f), [Commit](https://github.com/open-webui/open-webui/commit/4e78b355efe0611f27fddef591c69f7c1259a9f6), [Commit](https://github.com/open-webui/open-webui/commit/74f95a9b0d6b89b241701422170d54e21e701baa), [Commit](https://github.com/open-webui/open-webui/commit/cc16e06c32de48bdf464db5814483a49f3527b63), [Commit](https://github.com/open-webui/open-webui/commit/1ea54c3217f487990b6a4c0f0d8ea675a479b6e1)
- ✏️ **File renaming in knowledge bases.** Files inside a knowledge base can now be renamed directly from the workspace, with the new name reflected wherever the file is referenced. [Commit](https://github.com/open-webui/open-webui/commit/3127f1b46255626ea92eb0b598e45078287303f1)
- 😀 **Emoji picker in message input.** A new emoji button in the rich text formatting toolbar lets you browse and insert emojis directly into your messages. [#24704](https://github.com/open-webui/open-webui/pull/24704)
- 🪄 **Per-chat skills toggle.** Skills can now be turned on or off for a conversation directly from the chat Integrations menu, the same way tools and capabilities already work, instead of only through the model preset. [#25036](https://github.com/open-webui/open-webui/issues/25036), [#25037](https://github.com/open-webui/open-webui/pull/25037)
- 🔎 **Access preview for users and groups.** Administrators can now preview exactly which models, knowledge bases, and tools a given user or group can access, making it easier to audit and verify permission setups. [Commit](https://github.com/open-webui/open-webui/commit/9c14740ffb009d550dcbd5d6c599dac57053f112)
- 📄 **Configurable knowledge base file page size.** Administrators can now request a larger page size when listing a knowledge base's files through the API, reducing the number of requests needed to retrieve large collections instead of paging through fixed increments of 30. [#25148](https://github.com/open-webui/open-webui/issues/25148), [Commit](https://github.com/open-webui/open-webui/commit/a4d1b3e9378a61a11dd9822a8dbb525f39753081)
- 🔃 **Persistent processing indicator for knowledge files.** Files still being processed in a knowledge base now keep showing a processing indicator across page reloads, so you can tell what's still ingesting after navigating away and back. [#25031](https://github.com/open-webui/open-webui/issues/25031), [Commit](https://github.com/open-webui/open-webui/commit/ad9f2eeb15a448147d5e47e6a391cabb9aefd0ea)
- 📑 **MinerU file type configuration.** Administrators can now configure which file types are processed by the MinerU document loader, via the new "MINERU_FILE_EXTENSIONS" setting, extending it beyond PDF to formats like DOCX, PPTX, and XLSX. [Commit](https://github.com/open-webui/open-webui/commit/d4030a8aa5d48c2a1cb06c461566844aca2530ab)
- 📃 **Legacy Word document support.** Older ".doc" Word files can now have their text extracted by the default document extraction engine, in addition to the modern ".docx" format. [Commit](https://github.com/open-webui/open-webui/commit/9e3e24e304f8ff210494380b46f614a2984cafb3)
- 📁 **Create subfolders from the folder header.** Chat folders can now have subfolders created directly from the folder header in the chat view, not just from the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/1f0948bcbef2af73b155535ea27762c522260afc)
- ⚡ **Faster initial page loads.** The configuration endpoint that loads on every page visit no longer runs an unnecessary user-count query, making the initial application load lighter on the database, especially on instances with many users. [Commit](https://github.com/open-webui/open-webui/commit/0adc090dcbe636c9c9645d8ec7f6b89ea514870b)
- 🚀 **Faster tool-enabled chat completions.** Chat completions that use multiple tools now start faster because the tools they reference are fetched from the database in a single batch query instead of one query per tool. [#24808](https://github.com/open-webui/open-webui/pull/24808), [Commit](https://github.com/open-webui/open-webui/commit/cc94a90b4d4d690bc7cb9f7124f2d6e552973970)
- 🏎️ **More responsive web search under load.** Web search through SearXNG, Google PSE, Brave, Serper, and Serpstack now uses non-blocking network calls, so the server stays responsive to other users while a search is in flight, and concurrent multi-query searches complete faster. [Commit](https://github.com/open-webui/open-webui/commit/b94245d2ee191e8ef118bf9de1ff7539503bfec9)
- 🐎 **Lighter Ollama backend connections.** Requests to Ollama backends now reuse a shared connection pool instead of opening a fresh session each time, reducing TCP and TLS handshake overhead for installs that poll Ollama frequently or have multiple backends configured. [Commit](https://github.com/open-webui/open-webui/commit/5d9a09a88a9094ebfcd249340be3aaee544b34d0)
- 💽 **Fewer redundant model-list writes.** On multi-instance deployments backed by Redis, the model list is no longer rewritten when it hasn't changed, cutting a major source of redundant writes. [#25469](https://github.com/open-webui/open-webui/issues/25469), [#25474](https://github.com/open-webui/open-webui/pull/25474), [Commit](https://github.com/open-webui/open-webui/commit/fd76b51ab2ad4c4192f2c98153e47888712c2009)
- 📉 **Faster websocket disconnect cleanup.** Disconnecting from a collaborative session no longer triggers a scan across the entire Redis keyspace, using a per-session index instead, which keeps disconnects cheap on large deployments. [#25466](https://github.com/open-webui/open-webui/issues/25466), [Commit](https://github.com/open-webui/open-webui/commit/c7de057a4a54cc80f366ad949417e78fae756d4c)
- 📝 **Frontmatter auto-fill for tools, functions, and skills.** Opening a tool, function, or skill editor now auto-fills the name, id, and description fields from the file's frontmatter, saving you from re-entering metadata already declared in the source. [#24649](https://github.com/open-webui/open-webui/pull/24649), [Commit](https://github.com/open-webui/open-webui/commit/ef975649b26d3e7cd589c49be2fc77cee80fad8f)
- 🪪 **More user placeholders in custom headers.** Custom-header templates for direct connections and tool servers now support "{{USER_EMAIL}}" and "{{USER_ROLE}}" alongside the existing user and session placeholders. [Commit](https://github.com/open-webui/open-webui/commit/ed73ef3d8df988b0e9646b82df5b1a453202ef8d)
- ⏱️ **Configurable MCP connection timeout.** The timeout for the initial handshake with an MCP tool server is now configurable via the new "MCP_INITIALIZE_TIMEOUT" setting, so servers that are slow to start or expose many tools can finish connecting instead of timing out. [#25011](https://github.com/open-webui/open-webui/pull/25011), [Commit](https://github.com/open-webui/open-webui/commit/4297c02b121180e239a61c483ac8477cc557d4ef)
- 📐 **Profile image size limit.** Administrators can now cap the size of inline profile images via the new "PROFILE_IMAGE_MAX_DATA_URI_SIZE" setting, bounding how much database and cache space inline avatars and model icons can consume. [#25468](https://github.com/open-webui/open-webui/issues/25468), [#25476](https://github.com/open-webui/open-webui/pull/25476)
- 🎫 **Wildcard OAuth role mapping.** Administrators can now set "\*" in the allowed OAuth roles to grant the user role to any authenticated OAuth user, instead of having to enumerate every accepted role. [#25062](https://github.com/open-webui/open-webui/pull/25062), [Commit](https://github.com/open-webui/open-webui/commit/07cbc91a8eba3a9a3b39588b2ae5de916930af70)
- 📊 **Paginated feedback history.** The feedback and evaluation history list is now paginated, keeping it responsive for instances that have accumulated large numbers of feedback entries. [Commit](https://github.com/open-webui/open-webui/commit/160a6694e4bd66fc42e6361947516e8e15414ef3)
- 🔘 **Bulk enable or disable automations.** Automations can now be enabled or disabled in bulk from an actions menu on the automations page, instead of toggling each one individually. [Commit](https://github.com/open-webui/open-webui/commit/675e9bee5af8b9fb390fcb235a6c2c4766e78c75)
- ➡️ **Optional auto-redirect to single sign-on.** Administrators can now enable "OAUTH_AUTO_REDIRECT" so that, on deployments with a single sign-on provider and no other login methods, users are sent straight to the provider instead of seeing a login page first. [#25067](https://github.com/open-webui/open-webui/pull/25067), [Commit](https://github.com/open-webui/open-webui/commit/d64ef1803d2f5cedb7b3a308151d08ff2cd2b8e1)
- ☁️ **Azure AI Foundry v1 with Entra ID.** Open WebUI now supports Azure AI Foundry's OpenAI v1 endpoint together with Microsoft Entra ID authentication, so these connections work without manual workarounds. [#24761](https://github.com/open-webui/open-webui/issues/24761), [#24985](https://github.com/open-webui/open-webui/pull/24985), [Commit](https://github.com/open-webui/open-webui/commit/eb4eebc3ce1042cb0d393bf890c1895db6e08b19)
- 🌎 **Linkup web search provider.** Administrators can now select Linkup as the web search provider from the admin settings, with options to configure the API key and search depth. [#24752](https://github.com/open-webui/open-webui/pull/24752), [Commit](https://github.com/open-webui/open-webui/commit/56c0d00e13c74d665124ec9f1cae78e83ca60b6a)
- 🧊 **Valkey vector database support.** Valkey can now be used as the vector database backend, configurable through new "VALKEY_URL" and related settings including index type, distance metric, and HNSW tuning. [#24769](https://github.com/open-webui/open-webui/pull/24769), [Commit](https://github.com/open-webui/open-webui/commit/c0f1aa291938bbef62db8d4013dbc498b0abea15)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Spanish (Spain), Swedish, German, Korean, Catalan, Russian, Irish, Simplified Chinese, Traditional Chinese, Finnish, Polish, Turkish, and Malay 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)
- 🛡️ **Tool server permission enforcement.** The per-user permission for inline tool servers is now enforced on chat-completion requests, so users without that permission can no longer bypass the admin setting by supplying tool servers directly in their requests. [Commit](https://github.com/open-webui/open-webui/commit/5cc1eb517094e3507915664817285f1e6e37a16d)
- 🔒 **Knowledge base access check in search tool.** The built-in knowledge search tool now verifies that the caller can access a knowledge base before searching it by id, preventing users from reading the contents of knowledge bases they have not been granted access to. [#25113](https://github.com/open-webui/open-webui/pull/25113)
- 🗄️ **Cross-user access to retrieval collections.** Resolving the documents used for retrieval now verifies the caller's access to each referenced file and rejects client-supplied collection names, preventing a crafted request from pulling another user's files or vector collections into its context. [Commit](https://github.com/open-webui/open-webui/commit/ee47c9c833f8889f3abe99d2266c56e8f8d40230)
- 🔣 **Collection name validation.** Vector collection names are now rejected unless they contain only safe characters, preventing malformed names from reaching the vector store or breaking out of a database query expression. [#24982](https://github.com/open-webui/open-webui/pull/24982)
- 🚫 **Unscoped retrieval collections denied by default.** Retrieval requests for collection names that don't correspond to a known file, memory, web-search, or knowledge base are now denied for non-admins by default, with a new "ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS" setting to restore the previous behavior if needed. [Commit](https://github.com/open-webui/open-webui/commit/c93f071700520a60833b7f9616c292b11f210880)
- 📜 **Prompt history authorization.** Comparing, deleting, and restoring prompt versions now verify the history entry belongs to the prompt you're authorized for, preventing access to or modification of another prompt's version history. [#25056](https://github.com/open-webui/open-webui/pull/25056)
- 🚦 **Code interpreter permission on the legacy path.** The legacy code-execution path now enforces the same permission and capability checks as the current one, so users without the code interpreter permission can no longer trigger code execution through it. [#24724](https://github.com/open-webui/open-webui/pull/24724)
- 🧱 **API key endpoint restriction bypass.** The endpoint allow-list that limits which paths an API key may reach is now matched against the routed request path directly, preventing a crafted request from slipping past the restriction. [#25123](https://github.com/open-webui/open-webui/pull/25123)
- 🚧 **System prompt bypass via request parameter.** The flag that skips a model's configured system prompt can no longer be set by external clients through a request parameter, so admin-configured system prompts can't be bypassed from the API. [#25156](https://github.com/open-webui/open-webui/pull/25156)
- 🚪 **Terminal proxy path traversal.** The terminal proxy now fully decodes request paths before validating them, blocking multi-encoded payloads that could otherwise escape the intended path. [#25157](https://github.com/open-webui/open-webui/pull/25157)
- 🪤 **Cache file path traversal.** The cache file server now requires an exact directory boundary match, closing a gap where a sibling directory whose name began with the cache directory's name could be used to serve files from outside it. [#25086](https://github.com/open-webui/open-webui/pull/25086)
- 🔀 **Ollama backend selection access check.** Requests can no longer target an arbitrary Ollama backend by index; a caller-supplied backend selector is now verified against the backends that actually serve the requested model. [Commit](https://github.com/open-webui/open-webui/commit/7139797be04030b9d1016782f1dbb251c5fe68bb)
- 🔓 **Cross-user file exfiltration via image URLs.** When a chat message references a file by id in an "image_url" field, the server now resolves that file only for its owner, an administrator, or a user with an explicit read grant, preventing other authenticated users from extracting a file's contents by routing it through the model. [#24625](https://github.com/open-webui/open-webui/pull/24625), [Commit](https://github.com/open-webui/open-webui/commit/c75fe8e74b72617c51282cc3ea0a2e8d9cdd9140)
- 📌 **Chat file attachment access checks.** Attaching files to a chat now links only files the caller can read, preventing a user from associating another user's file with their chat to access its contents. [#25054](https://github.com/open-webui/open-webui/pull/25054)
- 🧾 **Model knowledge file ownership checks.** Creating or updating a model now verifies that any knowledge files attached to it are files the editor can access, preventing another user's files from being attached to a model. [#25055](https://github.com/open-webui/open-webui/pull/25055), [Commit](https://github.com/open-webui/open-webui/commit/27fb20c13a4bf8501f7a485abe0654eb5880980d)
- 📅 **Calendar event move authorization.** Updating a calendar event to move it into a different calendar now requires write access on the destination calendar, preventing users from injecting events into calendars they cannot write to. [#24764](https://github.com/open-webui/open-webui/pull/24764)
- 📣 **Channel chat access control.** Generating a response in a channel context now verifies the caller's access to that channel and scopes the included messages, preventing access to channels or messages the user isn't permitted to see. [#24725](https://github.com/open-webui/open-webui/pull/24725)
- 🕸️ **Web loader SSRF gating with Playwright.** When the Playwright-based web loader is in use, page navigations and redirects are now validated the same way as the default loader, closing a gap where the Playwright path could reach internal or otherwise blocked URLs. [#24756](https://github.com/open-webui/open-webui/pull/24756)
- 🛂 **DNS rebinding protection for URL fetches.** The IP address validated for an outbound URL fetch is now the same one used for the actual connection, closing a DNS rebinding window where an attacker-controlled hostname could resolve to a public IP during the safety check and then to a private IP when the connection was opened. [#24759](https://github.com/open-webui/open-webui/pull/24759)
- 🪞 **OAuth profile picture redirect handling.** The OAuth profile picture fetch now follows redirects only when administrators have explicitly allowed it, closing a window where a redirect from an externally validated URL could be used to reach internal addresses. [#24809](https://github.com/open-webui/open-webui/pull/24809)
- 🧼 **Model profile image script injection.** Model profile images are now validated on save and only served inline when they are a known-safe image type, preventing a crafted SVG profile image from running scripts in other users' browsers, while existing legacy images that fail validation are cleared gracefully instead of breaking the model list. [#25060](https://github.com/open-webui/open-webui/pull/25060), [#25173](https://github.com/open-webui/open-webui/pull/25173)
- 🧯 **Diagram rendering script injection.** Mermaid diagrams rendered in chat are now sanitized before display, preventing a crafted diagram from running scripts in the viewer's browser. [#25219](https://github.com/open-webui/open-webui/pull/25219)
- 🔐 **Shared-chat file write protection.** Access to a file through a shared chat now only grants read access, so users who can read a shared chat can no longer modify or delete files attached to it. [#24755](https://github.com/open-webui/open-webui/pull/24755)
- 🔏 **Cross-origin embed prompt control.** When Open WebUI is embedded in an iframe on a different origin, the embedding page can now only drive the chat input or submit prompts if the user has explicitly opted in via the "iframe Sandbox Allow Same Origin" setting, preventing untrusted host pages from triggering confirmation dialogs or controlling the chat. [#24767](https://github.com/open-webui/open-webui/pull/24767), [Commit](https://github.com/open-webui/open-webui/commit/eb3076c1b02d90c2ce6e6d3beb08a37987c740ec)
- 🗂️ **Chat folder ownership checks.** Creating a chat or updating a chat's folder now verifies the referenced folder belongs to the current user, preventing chats from being associated with folders owned by other people. [#24588](https://github.com/open-webui/open-webui/pull/24588)
- 🧩 **Chat recovery from corrupted history.** Chats whose internal message graph was left in a malformed state by a failed regeneration now open and load correctly, with missing roles, parent references, and current-message pointers reconstructed automatically instead of breaking the chat. [#24424](https://github.com/open-webui/open-webui/issues/24424), [#24157](https://github.com/open-webui/open-webui/issues/24157), [#20474](https://github.com/open-webui/open-webui/issues/20474), [#24799](https://github.com/open-webui/open-webui/pull/24799), [Commit](https://github.com/open-webui/open-webui/commit/d310a0777c4c48ec772bdc9a510005d5e91b09c7)
- 📨 **Imported chats with folders appear correctly.** Importing grouped chats no longer leaves them invisible when a referenced folder is missing; such chats now appear in the chat list instead of being silently orphaned. [#24910](https://github.com/open-webui/open-webui/issues/24910), [Commit](https://github.com/open-webui/open-webui/commit/7f7cd210186cb6a67e028c17974eb210fc7ba9fd)
- 🎟️ **MCP tool server sessions stay connected.** OAuth-authenticated MCP tool server sessions are no longer mistakenly refreshed and deleted by the single sign-on session handler, so those connections stay active. [#24618](https://github.com/open-webui/open-webui/issues/24618), [Commit](https://github.com/open-webui/open-webui/commit/c8eb8edca4174ec68fabd071d6b08c0bc07f8117)
- 🤝 **MCP OAuth scope discovery.** The OAuth flow for MCP tool servers now reads the scopes a server advertises through its Protected Resource Metadata, so connecting to servers that declare their own scopes succeeds. [#24730](https://github.com/open-webui/open-webui/issues/24730), [#24690](https://github.com/open-webui/open-webui/pull/24690)
- 🔍 **Web search reliability.** Web search again fetches page content reliably with the default web loader engine, a new "USER_AGENT" environment variable lets administrators set a real browser user-agent so fetches aren't blocked by Cloudflare, Wikipedia, and other bot-detection systems, and the startup script no longer fails to launch when these new environment variables are unset. [#24560](https://github.com/open-webui/open-webui/issues/24560), [#24793](https://github.com/open-webui/open-webui/issues/24793), [#24683](https://github.com/open-webui/open-webui/pull/24683), [Commit](https://github.com/open-webui/open-webui/commit/f60733758272c4532cd032e518c9cd73f648043a)
- 🔥 **Firecrawl web search results.** Web search using Firecrawl now returns results correctly regardless of which response format the Firecrawl version uses. [#24712](https://github.com/open-webui/open-webui/pull/24712)
- 🦅 **Kagi web search.** Web search using Kagi works again after its API endpoint and request method were updated to match Kagi's current API. [#25015](https://github.com/open-webui/open-webui/pull/25015)
- 🔢 **Bracketed numbers in code blocks.** Numbers in square brackets such as "[0]" inside code blocks are no longer stripped out as if they were source citations, so code displays and copies correctly. [#24948](https://github.com/open-webui/open-webui/issues/24948), [Commit](https://github.com/open-webui/open-webui/commit/e90a618f4555cea2c024bb60c1332ca04eed96da)
- 🔌 **API chat completions reliability.** Direct calls to the chat completions API no longer fail with an internal error when no chat session identifier is supplied. [#24553](https://github.com/open-webui/open-webui/issues/24553), [#25235](https://github.com/open-webui/open-webui/issues/25235), [Commit](https://github.com/open-webui/open-webui/commit/bc244fdc90504824b76654880898bf3f6649c299), [Commit](https://github.com/open-webui/open-webui/commit/f16b5c446027eae1bd767617bac2fdf54b24d6fc)
- 🖼️ **ComfyUI image generation and editing.** Generating and editing images via a ComfyUI backend now works again, including when ComfyUI is hosted on a private or internal network where URL validation was previously blocking the admin-configured endpoint. [#24565](https://github.com/open-webui/open-webui/issues/24565), [Commit](https://github.com/open-webui/open-webui/commit/7dcd932ad7cb5feab007737c6e0281def5cd47fc), [Commit](https://github.com/open-webui/open-webui/commit/8aa2a42dc7887512e697ff85d044220945ced40f)
- 🖌️ **Image generation with non-standard response headers.** Image generation now works with backends that return valid JSON without a standard content-type header, instead of rejecting the response. [#24838](https://github.com/open-webui/open-webui/pull/24838)
- 🐘 **Knowledge search on large documents.** Searching knowledge bases on PostgreSQL no longer fails when scanning across documents with very large extracted text content. [#24670](https://github.com/open-webui/open-webui/issues/24670), [Commit](https://github.com/open-webui/open-webui/commit/d74ee34d9128295faa116c919bd1dcca77744975)
- 💬 **Chat title generation.** Automatically generated chat titles now use the model currently selected in the dropdown for the active chat and fall back to the model from the active message branch otherwise, and a clear message is shown if no model is available instead of an unhelpful error. [#24604](https://github.com/open-webui/open-webui/issues/24604), [#24745](https://github.com/open-webui/open-webui/issues/24745), [Commit](https://github.com/open-webui/open-webui/commit/e5c8f8110a88739e011d8ab23e5cb11b4768469f), [Commit](https://github.com/open-webui/open-webui/commit/3c5e7968f0b5130890e17f1f2a2a52297cb145a6)
- 🧮 **Message search and analytics consistency.** Edits, deletions, and branch changes made in a chat are now reflected in message search results and analytics counts instead of leaving stale entries behind. [#25205](https://github.com/open-webui/open-webui/pull/25205), [Commit](https://github.com/open-webui/open-webui/commit/aa06200f789a4c3fc54ea9a141cd347d76cda4c5)
- 🩹 **Graceful handling of in-chat task failures.** When web search query generation, image prompt generation, or a tool call fails or references a missing tool, the chat now falls back or surfaces a clear error instead of breaking partway through the response. [#25038](https://github.com/open-webui/open-webui/issues/25038), [#25144](https://github.com/open-webui/open-webui/issues/25144), [Commit](https://github.com/open-webui/open-webui/commit/b64fd988f02b8a4295193892b24b00bdf635a4dc)
- 🎛️ **Filter changes to message output.** Filter functions that modify a message's structured output after generation now have those changes saved and displayed, instead of being discarded when only the output, not the text content, was changed. [#24884](https://github.com/open-webui/open-webui/pull/24884)
- ⏩ **Titles and tags reflect filtered output.** Outlet filters now run before automatic title, tag, and follow-up generation, so those are based on the final filtered message instead of the unfiltered version. [#24717](https://github.com/open-webui/open-webui/pull/24717)
- 💾 **Action-replaced message content persists.** Message content replaced by an action function through its event emitter is now kept when the chat is saved, instead of reverting to the original after a page reload. [#24585](https://github.com/open-webui/open-webui/issues/24585), [#25485](https://github.com/open-webui/open-webui/pull/25485)
- 🏷️ **Skill mentions in messages.** Mentioning a skill in a message now keeps the skill's name as readable text instead of removing it, and selecting a skill without typing anything no longer causes an error on providers that reject empty messages. [#24929](https://github.com/open-webui/open-webui/issues/24929), [Commit](https://github.com/open-webui/open-webui/commit/01810e32ad51305ca3247e8f83da95f6e6260f0c)
- 🧹 **Usage timer cleanup on send failure.** The background usage-stats timer started during message generation is now always cleared, even when sending a message fails, preventing leaked timers from accumulating over a session. [#25478](https://github.com/open-webui/open-webui/pull/25478)
- 🗑️ **Background tasks stop when a chat is removed.** Deleting or archiving a chat now cancels any in-flight generation or title and tag tasks for it, instead of leaving orphaned background work running. [#25050](https://github.com/open-webui/open-webui/pull/25050), [Commit](https://github.com/open-webui/open-webui/commit/778dba1d6b8dc3962163fa7bf9d802d9a07fba26)
- ⌨️ **Responsive knowledge file search.** Searching for knowledge files in the chat picker and model knowledge selector now matches on file names by default instead of scanning the full extracted text of every document on each keystroke, keeping the search responsive on large deployments, with content search available as an explicit opt-in. [#25082](https://github.com/open-webui/open-webui/issues/25082), [#25119](https://github.com/open-webui/open-webui/pull/25119), [Commit](https://github.com/open-webui/open-webui/commit/591e0aafa1d5e21dcd9fa1279dada2076968b9cb)
- 📥 **Document processing with empty embeddings.** Saving documents to the vector database no longer crashes when an embedding step returns no vectors, allowing the process to continue instead of failing the whole upload. [#25166](https://github.com/open-webui/open-webui/issues/25166)
- 🔤 **Non-UTF-8 text and CSV uploads.** Text and CSV files saved in legacy encodings, including Latin-1, Windows-1252, and Chinese encodings such as GB18030, are now detected and loaded correctly instead of being rejected as binary or failing with an empty-content error. [#25172](https://github.com/open-webui/open-webui/issues/25172), [#24973](https://github.com/open-webui/open-webui/issues/24973), [Commit](https://github.com/open-webui/open-webui/commit/6f0277db52d005420480abb0702d421525d6ea8b), [Commit](https://github.com/open-webui/open-webui/commit/1bbb2b933d4cee70a5102eca4109e77b8625f8fc)
- 🧽 **Null bytes in nested data no longer break saves.** Data containing null bytes nested inside structured fields is now sanitized correctly before being written, preventing database errors that the previous check failed to catch. [#25018](https://github.com/open-webui/open-webui/pull/25018), [Commit](https://github.com/open-webui/open-webui/commit/e3ab4bd212e44c39f439ec4ff5df7c3dbd046895)
- 🧠 **Clear error when no embedding model is configured.** Using knowledge or retrieval features without a loaded embedding model now returns a clear setup error explaining what to configure, instead of failing with a cryptic crash. [Commit](https://github.com/open-webui/open-webui/commit/55ca719bbf76306ed647424c728f730d0bef21f9)
- 🧲 **Memory search quality.** Memory searches now apply the configured embedding query prefix, so retrieval works correctly with embedding models that require one for queries. [#24921](https://github.com/open-webui/open-webui/pull/24921), [Commit](https://github.com/open-webui/open-webui/commit/ce4dca47cb19a6582fd8a550806c89ab297c038d)
- 📚 **Knowledge tool context overflow.** The built-in tool that lists a model's knowledge no longer dumps every file in every knowledge base into the model's context; it now returns summaries by default and paginates file listings only for a requested knowledge base. [#25105](https://github.com/open-webui/open-webui/pull/25105), [Commit](https://github.com/open-webui/open-webui/commit/0e73f7af099e1f9437c55a5c2d16741c97dbd57f)
- ⏳ **Terminal session stability.** The terminal proxy no longer hangs when one direction of the connection closes before the other, so terminal sessions shut down cleanly instead of stalling. [#25464](https://github.com/open-webui/open-webui/issues/25464), [#25479](https://github.com/open-webui/open-webui/pull/25479)
- 🧷 **Tool call continuity with strict providers.** Chats that contain incomplete tool calls or orphaned tool results no longer fail to continue when sent to providers that strictly validate tool pairings, such as Anthropic and AWS Bedrock Converse. [#24758](https://github.com/open-webui/open-webui/issues/24758), [#24940](https://github.com/open-webui/open-webui/issues/24940), [#24798](https://github.com/open-webui/open-webui/pull/24798), [Commit](https://github.com/open-webui/open-webui/commit/cfa6908d579e1f7f202a321289029996130b8411)
- 🛑 **Stream termination for pipe functions.** Streamed responses from pipe functions now always send the standard end-of-stream marker, so chat clients and external integrations reliably detect when a response is complete instead of waiting on streams that already finished. [#24763](https://github.com/open-webui/open-webui/pull/24763)
- 🔊 **Non-blocking text-to-speech transcoding.** Converting text-to-speech audio to MP3 no longer blocks the server's event loop, so other requests stay responsive even while a TTS response is being transcoded. [#24876](https://github.com/open-webui/open-webui/pull/24876)
- 🎚️ **Default text-to-speech voice.** Text-to-speech requests now honor the voice specified in the request and fall back to the configured default only when none is given, instead of always using the admin default or failing. [#15143](https://github.com/open-webui/open-webui/issues/15143), [#25035](https://github.com/open-webui/open-webui/issues/25035), [Commit](https://github.com/open-webui/open-webui/commit/f16b5c446027eae1bd767617bac2fdf54b24d6fc), [Commit](https://github.com/open-webui/open-webui/commit/750604a11d4adcb5ae568b9fc93010031ad394f9)
- 🪝 **Reliable knowledge base file linking.** Files uploaded to a knowledge collection are now linked on the server as part of the upload itself, so they remain attached to the collection even if you navigate away or close the page before processing finishes. [#24807](https://github.com/open-webui/open-webui/issues/24807), [Commit](https://github.com/open-webui/open-webui/commit/d0b17f056911ec73df2b686a0d92bb789391db27)
- ☁️ **Azure connections on custom hostnames.** Connections marked as the Azure provider now use the Azure code path even when the endpoint does not contain "azure" in its hostname, fixing custom Azure deployments served from non-standard domains. [#24882](https://github.com/open-webui/open-webui/pull/24882), [Commit](https://github.com/open-webui/open-webui/commit/c8f851bd2de3d127f1e818fc116c27174f96d3f1)
- 🗓️ **Clearing calendar event fields.** Removing the description or location from a calendar event now saves correctly instead of silently keeping the previous value. [#25026](https://github.com/open-webui/open-webui/issues/25026), [Commit](https://github.com/open-webui/open-webui/commit/91810f1c4e93d4f559bdf61cd085cadc288a732d), [Commit](https://github.com/open-webui/open-webui/commit/78b1637a035d71099262412e5dee3e4d65c7fb2f)
- 💭 **Advanced parameter settings.** Custom reasoning tags and custom model parameters are now saved correctly instead of being dropped, and the presence penalty and repeat penalty no longer save the frequency penalty's value instead of their own. [#25183](https://github.com/open-webui/open-webui/pull/25183), [#25200](https://github.com/open-webui/open-webui/pull/25200), [#25204](https://github.com/open-webui/open-webui/pull/25204)
- 📏 **Long username display.** Long usernames no longer overflow their containers in the admin user list, user modals, and sidebar. [#25185](https://github.com/open-webui/open-webui/pull/25185)
- 🎯 **All skills selectable in the model editor.** The model editor's skills selector now lists every skill you have access to, with a search box for large lists, instead of showing only the first 30 with no way to reach the rest. [#24873](https://github.com/open-webui/open-webui/issues/24873), [Commit](https://github.com/open-webui/open-webui/commit/936d5f2676dfbd2ba763b30af33a8557dcda9b05)
- 🔔 **Accurate knowledge upload feedback.** Dragging files into a knowledge base no longer shows an upload notification before the upload has actually been processed. [#25484](https://github.com/open-webui/open-webui/pull/25484)
- ♿ **High-contrast timestamp readability.** The user message timestamp now uses the correct colors in high-contrast mode instead of inverted ones, keeping it readable. [#25461](https://github.com/open-webui/open-webui/pull/25461)
- ♿ **Keyboard and screen reader access to menus.** The integrations, more-options, and user menus are now real buttons with labels and keyboard support, so they can be opened with the keyboard and announced by screen readers. [Commit](https://github.com/open-webui/open-webui/commit/346dab3d8f909fc321a49ea2be633ea5c4c4a349)
- 🖱️ **Focus-loss handling in editors.** Workspace and admin editors for models, tools, functions, and skills again respond correctly when the browser window loses focus, after the wrong event name was being listened for. [#25459](https://github.com/open-webui/open-webui/pull/25459)
- 🛟 **Resilience to corrupted local storage.** Corrupted data in the browser's local storage no longer crashes the interface; affected settings and dismissed-banner state now fall back to safe defaults. [#25481](https://github.com/open-webui/open-webui/pull/25481)
- 📶 **Quieter reconnection notifications.** Brief connection interruptions, such as backgrounding a mobile tab, no longer flash a "connection lost" warning, and the "reconnected" message only appears if a disconnect was actually shown. [Commit](https://github.com/open-webui/open-webui/commit/77c8c54b1ea07189e21313f9cd401f3b956bb93a)
- 🍎 **Safari PDF handling.** PDF processing now works in Safari, which doesn't support the stream iteration the previous code relied on. [#25151](https://github.com/open-webui/open-webui/issues/25151), [#25473](https://github.com/open-webui/open-webui/pull/25473)
- 🎙️ **Voice mode mute shortcut listing.** The keyboard shortcut for muting voice mode now appears in the keyboard shortcuts help modal. [#25193](https://github.com/open-webui/open-webui/pull/25193)
- 📎 **Document attachments in channel model replies.** Tagging a model in a channel thread now forwards uploaded non-image documents such as PDFs and DOCX files into the model's context, so document summarization and comparison workflows that already worked in direct chat now work in channels too. [#24896](https://github.com/open-webui/open-webui/issues/24896), [#24898](https://github.com/open-webui/open-webui/pull/24898), [Commit](https://github.com/open-webui/open-webui/commit/7e9d41d664d7065a92ac93606d18e88c45eb36b6)
- 🙈 **Hidden models in channel mentions.** Models marked as hidden no longer appear in the channel message-input model mention selector, matching how hidden models are excluded elsewhere in the interface. [#24892](https://github.com/open-webui/open-webui/pull/24892)
- 🧵 **Channel thread and pinned message stability.** Opening a channel thread or the pinned messages view no longer fails to render when a message or its data is missing. [#25209](https://github.com/open-webui/open-webui/pull/25209)
- 📺 **YouTube short link transcripts.** Pasting a "youtu.be" short link into a chat now loads the video transcript correctly instead of failing with an empty-content error. [#24856](https://github.com/open-webui/open-webui/issues/24856), [Commit](https://github.com/open-webui/open-webui/commit/1e36a206008c3abce5ccdc98d5750e30a7345b98)
- 🙉 **Hidden models in default-model and automation pickers.** The admin pickers for default models and default pinned models, and the automation model dropdown, now filter out hidden models, consistent with how hidden models are treated elsewhere. [#24869](https://github.com/open-webui/open-webui/issues/24869), [Commit](https://github.com/open-webui/open-webui/commit/1fa3050f069a72de1daaaa29233b545c4deaea51), [Commit](https://github.com/open-webui/open-webui/commit/4705c2d98812c1189c2ae4960399bc8888d4861c)
- 🔊 **Speech-to-text SSL setting honored.** Speech-to-text requests now respect the "AIOHTTP_CLIENT_SESSION_SSL" setting, so administrators using self-signed certificates or custom SSL configurations can use STT engines that were previously failing TLS verification. [#24568](https://github.com/open-webui/open-webui/issues/24568), [#24857](https://github.com/open-webui/open-webui/pull/24857), [Commit](https://github.com/open-webui/open-webui/commit/2ca91ceeeca6a6a21e5a6ad68ea3ab8c9d9f6deb), [Commit](https://github.com/open-webui/open-webui/commit/94b66b17972e0ad77954df4b81a6bb86a7a6a04b)
- 🔗 **Placeholders in MCP connection headers.** Custom header templates configured on MCP server connections now have their "{{USER_ID}}", "{{USER_NAME}}", "{{USER_EMAIL}}", "{{USER_ROLE}}", "{{CHAT_ID}}", and "{{MESSAGE_ID}}" placeholders interpolated at request time, matching how custom headers already work for direct connections and tool servers. [#24822](https://github.com/open-webui/open-webui/pull/24822)
- 🪟 **Bing search CLI smoke test.** Running the Bing web-search module from the command line for a quick connectivity check no longer raises an error about missing arguments. [#24765](https://github.com/open-webui/open-webui/issues/24765), [#24768](https://github.com/open-webui/open-webui/pull/24768)
- 🩺 **Database health check recovery.** After a transient database connection error, the health check endpoint now recovers automatically instead of staying permanently broken on the affected worker. [Commit](https://github.com/open-webui/open-webui/commit/0b81520e072bb4ed15532b8e153b43dd3243feaf)
- 🥾 **Startup on non-Unicode consoles.** Open WebUI no longer crashes at startup when the console can't encode the banner's box-drawing characters, such as on Windows or with redirected or headless output, falling back to a plain-text banner instead. [#24965](https://github.com/open-webui/open-webui/issues/24965), [#25482](https://github.com/open-webui/open-webui/pull/25482)
- 🆕 **First admin signup after a reset.** Creating the first administrator account is no longer blocked by a previously stored signup setting, so a fresh or reset instance can always be bootstrapped. [#24821](https://github.com/open-webui/open-webui/pull/24821)
- 🪵 **JSON exception logging.** With JSON log formatting enabled, exceptions are now recorded correctly with a structured type, message, and stacktrace instead of being dropped, and a logging failure can no longer crash the application. [#25135](https://github.com/open-webui/open-webui/issues/25135), [Commit](https://github.com/open-webui/open-webui/commit/79bf3d28d88e78b0136ef3c3c9f8e7bb85d3cea9)
- 🧭 **Workspace skills permission.** Users granted only the "workspace.skills" permission can now see the workspace entry in the sidebar and are correctly routed to the skills page from the workspace index. [#24729](https://github.com/open-webui/open-webui/pull/24729)
- 🔁 **Resilient database migrations.** Database migrations now skip tables, indexes, and columns that already exist and add missing primary keys to legacy tables, so upgrades succeed even when parts of the schema were manually or partially created beforehand. [Commit](https://github.com/open-webui/open-webui/commit/81f611fb73c726cfcfc20ee57a926a543f07e95f), [Commit](https://github.com/open-webui/open-webui/commit/f0e88dadc8502ea05b1a00f3155bee7d1cf32249), [Commit](https://github.com/open-webui/open-webui/commit/bd9f82d5a681ee94bde44325033447500a0c76bf), [Commit](https://github.com/open-webui/open-webui/commit/459b1c3fda2ec3579fbd2ab408d0fdeb07c96b99), [Commit](https://github.com/open-webui/open-webui/commit/6df09a4039d181f4e2d41324e93cd36c6fb27dfd), [Commit](https://github.com/open-webui/open-webui/commit/95840e307a66429168775de389b329487a4311c4), [Commit](https://github.com/open-webui/open-webui/commit/6b1df94bf933af92f5c7d093a2d92e2e50d6fdd0), [Commit](https://github.com/open-webui/open-webui/commit/98d3b2308564e2112290773ea240b90419ebb48d), [Commit](https://github.com/open-webui/open-webui/commit/1b9d22e324181b96511a15cbacc40fdbb439ad79), [Commit](https://github.com/open-webui/open-webui/commit/dc0f8ae6f2372b6e5dd42f3520813b09c54146c0), [Commit](https://github.com/open-webui/open-webui/commit/ee3b14233a642ff1bc38aef03a2418afcf5d6c1d), [Commit](https://github.com/open-webui/open-webui/commit/1004dad2749bcc369d511315a808d4bb5277854c), [Commit](https://github.com/open-webui/open-webui/commit/db2b3d7fd86cc7d76424671ead3c62a3f6302fe7), [Commit](https://github.com/open-webui/open-webui/commit/2e1b671e8db49f47d69fd59ece2b58e109ec8b84), [Commit](https://github.com/open-webui/open-webui/commit/9a8969ca93f8c9109d956518a085be3e2afacc07), [Commit](https://github.com/open-webui/open-webui/commit/9717ada92fdb3761a217d309f322ee9fbe418d0d), [Commit](https://github.com/open-webui/open-webui/commit/d7cfc1e46a8f3e5c8f6758c12ef641f41fb51a39), [Commit](https://github.com/open-webui/open-webui/commit/9263b7568eaa83f43e4a118c98490c4f7aaba570), [Commit](https://github.com/open-webui/open-webui/commit/73d2065227e651cf90a2cfade721516815743ab4), [#24722](https://github.com/open-webui/open-webui/pull/24722)
### 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.
- ⚙️ **Tool-call iteration cap renamed and raised.** The environment variable that limits how many tool calls a single chat response may make is now "CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS", with its default raised from 30 to 256 and a new "-1" value for unlimited; the previous "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES" name continues to work as a fallback, and chats that hit the cap now show a clear error in-chat instead of stopping silently. [#24918](https://github.com/open-webui/open-webui/pull/24918), [Commit](https://github.com/open-webui/open-webui/commit/2b99945d2726bdf5aed7b1712f9e3b7b622671df)
- 🔐 **Reduced public "/api/config" exposure.** The "/api/config" response no longer includes several feature flags ("enable_api_keys", "enable_password_change_form", "enable_version_update_check", "enable_public_active_users_count", "enable_easter_eggs") for unauthenticated callers, reducing information disclosure to anonymous visitors. [Commit](https://github.com/open-webui/open-webui/commit/245e0ee029e9e10617f62953a9e6f67dd00ecf81), [Commit](https://github.com/open-webui/open-webui/commit/ae06e199d5d3f65296a978cc35079bdacba596d2)
- 🔑 **"WEBUI_SECRET_KEY" is now a hard requirement even for unsupported deployments.** Deployments that start the backend in an explicitly unsupported way (such as invoking uvicorn directly) without setting "WEBUI_SECRET_KEY" will now refuse to start instead of falling back to an empty key; the supported start methods (start.sh, start_windows.bat, and "open-webui serve") still set or auto-generate it automatically, so standard deployments are unaffected. Direct Uvicorn startup is not supported. [#25218](https://github.com/open-webui/open-webui/pull/25218)
## [0.9.5] - 2026-05-09
### Added
- 🛡️ **Redirect-based SSRF protection.** All outbound HTTP requests now block 3xx redirects by default via a new `AIOHTTP_CLIENT_ALLOW_REDIRECTS` environment variable, preventing redirect-based SSRF where a public URL silently redirects to internal addresses (RFC 1918, loopback, cloud-metadata endpoints). Affected call sites include web fetch, image loading, OAuth discovery, tool server execution, and code interpreter login. [#24491](https://github.com/open-webui/open-webui/pull/24491)
- 🛡️ **Iframe content security policy.** Administrators can now configure a Content-Security-Policy for all srcdoc iframes (Artifacts, tool embeds, file previews, citation modals) via the `IFRAME_CSP` environment variable, restricting what LLM-generated or user-uploaded HTML can load and execute inside previews. [Commit](https://github.com/open-webui/open-webui/commit/3bba1c227059a44c7eeefa97b8c69a63bf4f3454)
- 🎛️ **Granular markdown rendering controls.** Users can now independently disable Markdown rendering for user messages and assistant responses from Interface settings, preventing unintended formatting when pasting text that contains Markdown-sensitive characters. [Commit](https://github.com/open-webui/open-webui/commit/4a1064cefd6f48a8b3b02cd31f77838c8802b635)
- 🔧 **Terminal proxy response headers.** Administrators can now inject custom response headers into terminal proxy responses via the `TERMINAL_PROXY_HEADERS` environment variable (JSON object), enabling deployment-specific security headers like sandbox policies for proxied content. [Commit](https://github.com/open-webui/open-webui/commit/8d3133fe2835122bffaa4f2ce584730bc9c78981)
- 🔌 **Channel streaming and tool support.** Mentioning a model in a Channel now streams responses in real time and supports the full chat completion pipeline, including native and default function calling, built-in tools (web search, image generation), user tools, MCP tools, filters, and RAG knowledge injection — the same capabilities available in standard chats.
### Fixed
- 📝 **Notes create and open reliability.** Creating new notes and opening existing notes no longer fails with a TypeError caused by `is_pinned` being passed to the SQLAlchemy model on create, and passed twice to `NoteResponse` on read. [#24484](https://github.com/open-webui/open-webui/issues/24484), [#24486](https://github.com/open-webui/open-webui/pull/24486)
- 🔐 **Skill public sharing permission enforcement.** Creating or updating skills now filters access grants through the `sharing.public_skills` permission, preventing non-admin users from making skills publicly accessible without the required permission. [#24494](https://github.com/open-webui/open-webui/pull/24494)
- 🔐 **Calendar public sharing permission enforcement.** Creating or updating calendars now filters access grants through a new `sharing.public_calendars` permission, preventing users from making calendars publicly readable or writable without explicit admin-granted sharing permission. [#24493](https://github.com/open-webui/open-webui/pull/24493)
- 🔐 **Feedback user attribution spoofing.** Submitting evaluation feedback can no longer forge the `user_id` field through mass-assignment, preventing authenticated users from attributing ratings to other users and corrupting Elo leaderboard rankings and admin feedback exports. [#24508](https://github.com/open-webui/open-webui/pull/24508)
- 🛡️ **Image URL redirect-based SSRF.** Chat messages containing image URLs no longer follow 3xx redirects to internal addresses during base64 conversion, closing the most reachable redirect-based SSRF variant that required no special permissions or feature flags. [#24524](https://github.com/open-webui/open-webui/pull/24524)
- 🛡️ **Collection write access on file processing.** The `process_file` and `process_files_batch` retrieval endpoints now enforce collection write-access checks before embedding content, preventing authenticated users from injecting file content into another user's knowledge-base collection. [#24524](https://github.com/open-webui/open-webui/pull/24524)
- 🔐 **Tool source code update authorization.** Updating a tool's Python source code now requires `workspace.tools` or `workspace.tools_import` permission, preventing users with only a write-access grant from overwriting executable tool code while still allowing metadata edits. [#24513](https://github.com/open-webui/open-webui/pull/24513)
- 🔐 **Channel message ownership enforcement.** Updating or deleting messages in group and DM channels now requires message ownership, preventing channel members from tampering with or silently removing other members' messages. [#24506](https://github.com/open-webui/open-webui/pull/24506)
- 🔐 **Channel pin write permission.** Pinning and unpinning messages on standard channels now requires write permission instead of read permission, preventing read-only users from modifying pinned content. [#24521](https://github.com/open-webui/open-webui/pull/24521)
- 🛡️ **Image generation URL validation.** Generated image URLs are now validated through `validate_url()` before fetching, aligning the defense-in-depth posture with sibling image-loading paths. [#24518](https://github.com/open-webui/open-webui/pull/24518)
- 🔐 **Model params exposure for read-only users.** The per-model API endpoint now strips the `params` dict (including system prompts) from responses to callers without write access, preventing read-only users from viewing admin-curated model configuration. [#24525](https://github.com/open-webui/open-webui/pull/24525)
- 🛡️ **URL parser SSRF bypass.** URL validation now rejects backslash, tab, CR, and LF characters that cause urllib and requests/aiohttp to disagree on the target host, closing a parser-confusion SSRF bypass. [#24534](https://github.com/open-webui/open-webui/pull/24534)
- 🛡️ **Profile image MIME-type allowlist.** Serving profile images from data URIs now enforces a strict MIME-type allowlist (PNG, JPEG, GIF, WEBP by default, configurable via `PROFILE_IMAGE_ALLOWED_MIME_TYPES`) and sets `X-Content-Type-Options: nosniff`, preventing stored-XSS through SVG or other executable content types. [Commit](https://github.com/open-webui/open-webui/commit/15e696691cad98692c329de62ed8a5bdb3a26d4e)
- 🔐 **File ownership in folder and knowledge attachments.** Attaching files to folders or knowledge bases now verifies per-file read access, and folder file lists in chat middleware are filtered to entries the caller can read, preventing unauthorized file content from being injected into RAG context. [Commit](https://github.com/open-webui/open-webui/commit/2dbf7b6764a7922458d3b0139687ad6dcd7596d9)
- 🔐 **Shared chat access for owners and admins.** Chat owners can now view and clone their own shared chats without requiring an explicit access grant, and administrators can manage shared chat access controls on any chat. [Commit](https://github.com/open-webui/open-webui/commit/3a21b334cce30226750c5c537345dc51bb8bef17), [Commit](https://github.com/open-webui/open-webui/commit/315566064aedeff071854b023d09e5f1ea0eb950)
- 🧵 **Legacy chat history self-healing.** Loading legacy conversations now automatically detects broken parent-link graphs in migrated message records, merges missing messages from the embedded JSON history, and backfills them to the normalized table so future loads use the fast path without data loss. [Commit](https://github.com/open-webui/open-webui/commit/1388f4568b8f508c26542673dd01f1fa049e798a)
- 🎛️ **Filter selector reactivity.** Model filter checkboxes now derive state reactively from the current filter list and selected IDs instead of capturing a one-time snapshot at mount, so checkboxes update correctly when model contexts or filter configurations change at runtime. [Commit](https://github.com/open-webui/open-webui/commit/d1ef5382377f590f97a6dbaee88f369e6d7c5f6f)
- 🌐 **Portuguese (Brazil) translation updates.** Translations for newly added UI items were added along with a consistency pass across existing entries. [#24503](https://github.com/open-webui/open-webui/pull/24503)
### Changed
- 🧹 **Removed unauthenticated retrieval status endpoint.** The unauthenticated `GET /api/v1/retrieval/` status endpoint has been removed as dead code — retrieval configuration is already available through authenticated admin endpoints. [#24497](https://github.com/open-webui/open-webui/pull/24497)
- 📋 **PR template issue requirement.** Pull requests now require a linked Issue or Discussion reference, ensuring better traceability for all contributions. PRs without a linked issue or discussion may be closed without review.
## [0.9.4] - 2026-05-09
### Fixed
- 📜 **Chat scroll position on load.** Opening a chat conversation now reliably scrolls to the bottom of the message history, fixing a regression caused by `content-visibility: auto` where estimated element sizes prevented the initial scroll from reaching the true bottom.
## [0.9.3] - 2026-05-09
### Added
- 🔇 **Voice Mode mute control.** Voice Mode now includes a dedicated mute toggle with an "M" shortcut and auto-unmute after assistant playback, so you can prevent accidental interruptions from background noise without leaving the call overlay. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c), [#23832](https://github.com/open-webui/open-webui/issues/23832)
- 🚀 **Faster prompt list loading.** Prompt and prompt-tag pages now load much faster for non-admin users, even with large prompt libraries, because accessible prompts are filtered efficiently in a single database query. [#24288](https://github.com/open-webui/open-webui/pull/24288), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
- ⚡ **Faster chat history loading.** Chat history maps now load from normalized message records when available, reducing overhead for large conversations while preserving fallback behavior for legacy chats. [Commit](https://github.com/open-webui/open-webui/commit/485d689cfd1ef8b9e7f77cd7b535b8b8747dff1f), [#23159](https://github.com/open-webui/open-webui/pull/23159)
- 🗑️ **Delete from conversation menu.** You can now delete the current conversation directly from the chat menu with a confirmation step, so cleanup is faster without searching through the full chat list. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4), [#24329](https://github.com/open-webui/open-webui/issues/24329)
- ⬆️ **Scroll to Top shortcut.** Long conversations now include a Scroll to Top action in the chat menu when you are away from the top, making it much faster to jump back to the beginning of a chat. [Commit](https://github.com/open-webui/open-webui/commit/cdfcbc4af6e9aec835b88dc1806a2a46711e6947), [#24133](https://github.com/open-webui/open-webui/issues/24133)
- 📅 **Calendar creation flow.** Users can now create calendars from a dedicated modal and a quick-add action in the calendar sidebar, making calendar setup faster from the calendar workspace. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840)
- 🧭 **Unified model unload controls.** Administrators can now unload running models from the model selector across supported providers, with loaded-state indicators shown for Ollama and llama.cpp models. [Commit](https://github.com/open-webui/open-webui/commit/4fe2de78643c2213652190d2820f4e8d9f4f89cc)
- ⚡ **Health check responsiveness.** Health and readiness probes now avoid blocking database calls and skip sync session commit handling on probe paths, improving responsiveness and reducing false unready transitions during database pressure. [#24380](https://github.com/open-webui/open-webui/pull/24380), [#24384](https://github.com/open-webui/open-webui/pull/24384)
- 🎛️ **Playground controls panel.** The Playground now includes a dedicated Controls toggle so you can adjust parameters like temperature and related settings per chat run without changing model-level defaults. [Commit](https://github.com/open-webui/open-webui/commit/c6763521c00f042a28829e33fb6f1b7355054046), [#24103](https://github.com/open-webui/open-webui/issues/24103)
- 🎙️ **STT file extension controls.** Administrators can now configure which audio file extensions are accepted for speech-to-text uploads, helping enforce safer and more predictable upload policies. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
- 📷 **Remembered call camera selection.** Voice call overlay now remembers your last selected camera and restores it automatically when available, so you do not need to reselect it every time you start voice mode. [Commit](https://github.com/open-webui/open-webui/commit/5c3edc2539ac4d92c4cc2d37079549995203238a), [#24416](https://github.com/open-webui/open-webui/issues/24416)
- 👥 **User group prompt variable.** System and template prompts now support the "{{USER_GROUPS}}" variable, which expands to the user’s group memberships so prompts can adapt to role- or access-based context automatically. [Commit](https://github.com/open-webui/open-webui/commit/c1202a23277abb8e7080271a929dcc9d29b67e66), [#24462](https://github.com/open-webui/open-webui/issues/24462)
- 🔐 **Public chat sharing permission control.** Administrators can now control whether users are allowed to create publicly shareable chats through a dedicated permission setting. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4)
- 🔐 **Profile image forwarding control.** Administrators can now disable external profile image URL forwarding with the "ENABLE_PROFILE_IMAGE_URL_FORWARDING" setting to prevent browser metadata leaks to third-party servers. [#24420](https://github.com/open-webui/open-webui/pull/24420)
- 🏷️ **Dynamic header template variables.** Administrators can now use chat, message, and user template variables in custom connection and tool server headers so each request can carry per-conversation context automatically. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
- 🛂 **MCP OAuth server URL setting.** Static OAuth tool server setups can now define a separate OAuth server URL, making discovery and client registration work when authentication endpoints are hosted separately from the tool server URL. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164), [#24216](https://github.com/open-webui/open-webui/issues/24216)
- ⚡ **Faster memory query performance.** Per-user memory lookups and deletions now run much faster at scale because the memory user filter is indexed for existing and new installations. [Commit](https://github.com/open-webui/open-webui/commit/38a382ef888685650135d61dcc8ec0e29eb65573), [#23836](https://github.com/open-webui/open-webui/pull/23836)
- 🚀 **Smarter function dependency installs.** Function dependencies are now skipped when they were already preinstalled and unchanged, reducing first-load delays and repeated package installation churn after startup. [Commit](https://github.com/open-webui/open-webui/commit/ae43562b869b24699408e5ab107261a0a8bdb4bc), [#24166](https://github.com/open-webui/open-webui/pull/24166)
- 🔎 **Brave LLM Context web search.** Administrators can now choose Brave LLM Context as a web search provider to retrieve richer grounded passages with a configurable context token budget. [Commit](https://github.com/open-webui/open-webui/commit/6700f7bb72d14a3f8dbb72dfa064cae3b3dc29ac), [#24120](https://github.com/open-webui/open-webui/issues/24120)
- 🗂️ **Open Terminal date sorting.** Open Terminal now includes sort controls for name and date, with directory-first ordering and modified-time visibility to make file browsing faster. [Commit](https://github.com/open-webui/open-webui/commit/6bdc2ffa79d72daf78981209c9c5292c697cbfe5), [#24425](https://github.com/open-webui/open-webui/issues/24425)
- 🎤 **Voice mode prompt toggle.** Administrators can now explicitly enable or disable the Voice Mode custom prompt behavior from Interface settings, giving finer control over how voice replies are guided. [Commit](https://github.com/open-webui/open-webui/commit/17893038869e3a763a8b34457f723b9666804e27)
- 🧮 **LaTeX copy shortcut.** You can now click rendered LaTeX expressions to copy the raw formula to your clipboard, making it easier to reuse equations outside chat. [Commit](https://github.com/open-webui/open-webui/commit/064fdecb675c176a04b024c16ce179f4dda45236), [#24244](https://github.com/open-webui/open-webui/pull/24244)
- ✨ **Smoother rich text editing.** The message composer now defers formatting toolbar refresh work to the next animation frame, reducing typing jank while formatting controls stay accurate. [Commit](https://github.com/open-webui/open-webui/commit/794b97025d4c56f91d49c9d1ec4775d2ea07b53a), [#24013](https://github.com/open-webui/open-webui/pull/24013)
- 🖼️ **Arena model profile images.** Arena models can now reliably display configured profile images instead of falling back to the default icon. [Commit](https://github.com/open-webui/open-webui/commit/1dee67b64d0b34e70bac949682b216c0aaec8152), [#24412](https://github.com/open-webui/open-webui/issues/24412)
- 🔄 **Replaceable tool embed updates.** Pipes and Tools can now overwrite previously emitted rich-UI embeds in-place by passing a `replace` flag on the `embeds` event, enabling live dashboards and progress panels that update without stacking duplicate entries.
- ✏️ **Assistant response editing and continuation.** You can now edit and restructure assistant output items — including reasoning blocks, tool calls, and text content — from a dedicated editor view, and continue generating from the edited state so the model receives full prior context.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Chinese, Catalan, Filipino, and Korean were enhanced and expanded.
### Fixed
- 🧵 **Background code execution reliability.** Code execution no longer hangs indefinitely when you switch conversations or browser tabs during a run, and disconnected or inactive sessions now fail with a clear timeout error instead of endless processing. [Commit](https://github.com/open-webui/open-webui/commit/552bbcecfae5ae273ab98e2ce3e540d0771aa964), [#24089](https://github.com/open-webui/open-webui/issues/24089)
- 🎙️ **Voice recording MIME fallback support.** Voice recording now tries a broader set of browser-supported audio formats and resets halted audio playback cleanly, improving microphone capture reliability in browsers where recording previously failed to start. [Commit](https://github.com/open-webui/open-webui/commit/8ffc3d746f20007e9eb4e3ae4f152f383bc371e1), [#24162](https://github.com/open-webui/open-webui/issues/24162)
- 🧠 **Direct-connection task generation reliability.** Title, tags, follow-up, emoji, query, and related task-generation endpoints now work correctly when chats use direct-connection models instead of failing with model-not-found errors. [Commit](https://github.com/open-webui/open-webui/commit/1b4cd705d0b9a51a5e3a7851ec012fb3141eb0a9), [Commit](https://github.com/open-webui/open-webui/commit/005df577fec16733a64edbec8a1b46f42f4e9a43), [#24092](https://github.com/open-webui/open-webui/issues/24092)
- 🔧 **Parameterized URL tool readiness.** New chats now wait for model defaults to finish applying before auto-submit, preventing early requests that can miss configured external tools. [Commit](https://github.com/open-webui/open-webui/commit/212bb68a66435dc1803a6d67cb4ea584d3455fb7), [#24176](https://github.com/open-webui/open-webui/issues/24176)
- 🚦 **MCP cleanup response reliability.** Successful native MCP tool calls no longer get replaced by a 500 "No response returned" error during cleanup, so valid chat responses are now returned consistently. [#24105](https://github.com/open-webui/open-webui/pull/24105)
- 🧵 **Active task state recovery.** Chat input no longer stays blocked by unrelated background tasks after a response is already complete, and interrupted assistant replies are now marked done more reliably. [Commit](https://github.com/open-webui/open-webui/commit/04bd0425ead28185bcd124e77892e31209a6e15b), [#23264](https://github.com/open-webui/open-webui/pull/23264)
- 📌 **Per-user note pinning behavior.** Pinned notes are now tracked per user instead of with a shared note-level flag, so one person’s pin changes no longer affect everyone else. [Commit](https://github.com/open-webui/open-webui/commit/33e588cf09b294f0abe08b9566efa8545a7dbf92)
- 🧱 **Custom header value coercion.** Custom header values are now converted to text before requests are sent, preventing request failures when non-text values are configured. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
- 🔗 **HTTP share link copy fallback.** Copy Link now works reliably on HTTP deployments by using a selection-based fallback when secure clipboard APIs are unavailable. [Commit](https://github.com/open-webui/open-webui/commit/f70b0da1563ffa0a8daecbe71cbc30fd8cf834c4), [#24135](https://github.com/open-webui/open-webui/issues/24135)
- 🧵 **Regeneration loading lock recovery.** Chats no longer get stuck in a permanent loading state after failed regenerations because invalid message-tree references are repaired before rendering. [Commit](https://github.com/open-webui/open-webui/commit/ee3b82926b37843f2771c6a8d432781a557ea96a), [#24424](https://github.com/open-webui/open-webui/issues/24424)
- 📸 **Complete chat image capture.** Downloaded chat snapshots now include all messages more reliably through visibility overrides and layout timing improvements during capture. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840), [#24088](https://github.com/open-webui/open-webui/issues/24088)
- 🗓️ **Calendar deletion lock handling.** Calendar deletion now avoids SQLite write-lock contention by revoking calendar access grants in a separate transaction after calendar and event removal. [Commit](https://github.com/open-webui/open-webui/commit/1d892ce2c513c4d933c902de5e5d76c317a06dd2)
- 🧩 **Filter and internal tool coexistence.** Internal tools now remain available when filters add provider-native tools, so filter-added tools no longer replace the built-in tool set during request processing. [Commit](https://github.com/open-webui/open-webui/commit/02f9fe78907c2ecf6f1d93646cbfa2173409bbe8), [#24237](https://github.com/open-webui/open-webui/issues/24237)
- 🛠️ **OpenAPI tool spec compatibility.** OpenAPI tool integrations now handle null or non-operation path entries more safely and parse path-level parameters consistently, preventing crashes and improving tool execution reliability across imperfect OpenAPI specs. [Commit](https://github.com/open-webui/open-webui/commit/2ba6b423aa0c9c800bd96cb638c6ade867cac0f6), [Commit](https://github.com/open-webui/open-webui/commit/5b80932e5951786bb348b91589e8d87753f18905), [#24376](https://github.com/open-webui/open-webui/pull/24376)
- 🧰 **OpenAPI tool schema parsing.** OpenAPI tool imports now ignore non-method path item fields and correctly resolve nested composition schemas, preventing invalid tool parsing for compatible specs. [Commit](https://github.com/open-webui/open-webui/commit/85c7373f68ac3e39a9cd37e63b6926b13fb8b8cc), [#23254](https://github.com/open-webui/open-webui/pull/23254)
- 🌍 **Web search proxy compatibility.** DuckDuckGo search now respects configured proxy environments more reliably, and trust-env behavior defaults to enabled so proxied web loading does not fail unexpectedly. [Commit](https://github.com/open-webui/open-webui/commit/bb0e6cb1085aa3c3da66a5f5ea1cecff7e9b5297), [#23810](https://github.com/open-webui/open-webui/pull/23810)
- 🧾 **Final markdown render flush.** Streaming markdown now forces an immediate final parse when generation completes, preventing stale or partially rendered final output. [Commit](https://github.com/open-webui/open-webui/commit/29f6c72e879d67f23021938e24a21914cc9fb120), [#24088](https://github.com/open-webui/open-webui/issues/24088)
- 🛡️ **Webhook avatar URL validation.** Channel webhook profile image URLs are now validated before saving, preventing invalid or unsafe avatar URLs from being accepted. [#24370](https://github.com/open-webui/open-webui/pull/24370)
- 📝 **System prompt editor scroll stability.** Editing large system prompts no longer jumps the page back to the top, so you can continue editing long model prompts without losing your place. [Commit](https://github.com/open-webui/open-webui/commit/c978a788c8315e37357c93c1b605a2831fc77485), [#23999](https://github.com/open-webui/open-webui/issues/23999)
- 🔎 **Knowledge content search matching.** Knowledge file search now matches both file titles and file content, so relevant files are easier to find even when the keyword is not in the filename. [Commit](https://github.com/open-webui/open-webui/commit/11e076817ae5db34621ce03136353248f7377d97), [#24297](https://github.com/open-webui/open-webui/pull/24297)
- ⚡ **Faster prompt tag loading.** Prompt tag filters now load much faster for non-admin users by fetching only accessible tags directly, avoiding per-prompt permission checks and unnecessary prompt data loading. [#24287](https://github.com/open-webui/open-webui/pull/24287), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
- 🧾 **Citation overflow badge readability.** Citation overflow badges now keep multi-digit counts readable in a single compact bubble, preventing wrapped or cramped display when many sources are attached. [Commit](https://github.com/open-webui/open-webui/commit/23ff9943a9fc8c314100fa074157853fbece1a55), [#24391](https://github.com/open-webui/open-webui/pull/24391)
- 🌐 **Yandex result parsing guard.** Yandex web search no longer fails when some XML fields are missing in individual results, so valid search responses continue to return usable sources instead of dropping to no results. [Commit](https://github.com/open-webui/open-webui/commit/9386fc83a3eff3e55cc157ac8c15c337e3d822c1), [#24243](https://github.com/open-webui/open-webui/issues/24243)
- 🎧 **Safer voice transcription uploads.** Empty or failed voice conversions are now rejected with a clear error instead of continuing as malformed audio, reducing failed transcription attempts from corrupted or near-empty recordings. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c)
- 🎚️ **Safer chunked STT processing.** Chunked transcription now limits worker concurrency when no external STT engine is configured, reducing failed transcription behavior caused by overly parallel local processing. [Commit](https://github.com/open-webui/open-webui/commit/55e7c7854bba5182803239c903a0ac2d14426a4c)
- 📈 **Imported chat analytics coverage.** Imported ChatGPT conversations now carry proper model and timestamp metadata and reliably write imported messages into analytics-backed storage, so imported chats are reflected correctly in Admin Analytics totals and model usage views. [Commit](https://github.com/open-webui/open-webui/commit/4d766a3edfa116abcefe7168f1d1284683b860b2), [#24263](https://github.com/open-webui/open-webui/issues/24263)
- 📎 **Knowledge collection persistence.** Knowledge collections selected with the chat input selector now remain attached after reloads and chat switches, so attached context no longer disappears between sessions. [Commit](https://github.com/open-webui/open-webui/commit/7c398a625a8d51f79d80217f6d329fc30c72b782), [#24142](https://github.com/open-webui/open-webui/issues/24142)
- 🧹 **Embedding model name trimming.** Embedding model names entered in Documents settings now automatically trim surrounding whitespace, preventing silent embedding failures caused by accidental trailing spaces. [Commit](https://github.com/open-webui/open-webui/commit/6082e1adaebc8aa3e7f55265c8dc2dbe130c0446), [#24090](https://github.com/open-webui/open-webui/issues/24090)
- 🔊 **PCM TTS playback compatibility.** Text-to-speech audio returned as PCM is now converted to MP3 before delivery, so speech playback works correctly with providers that return raw PCM audio. [Commit](https://github.com/open-webui/open-webui/commit/ff791b4814fc1453df2235ea78016d7015aa6806), [#24143](https://github.com/open-webui/open-webui/issues/24143)
- 🪟 **Windows PostgreSQL startup compatibility.** Windows pip installs using PostgreSQL now start reliably with psycopg async by using a compatible event loop policy instead of the default Proactor loop. [Commit](https://github.com/open-webui/open-webui/commit/7eaecbad5a0913ed04ca3bc10c930bb051dd2bd9), [#24152](https://github.com/open-webui/open-webui/issues/24152)
- ⏱️ **MCP OAuth timeout control.** OAuth token exchanges for MCP tool server connections now respect the configurable client timeout setting, reducing callback failures with slower providers. [Commit](https://github.com/open-webui/open-webui/commit/cde72dab71671645e119564ca9747ce25dd590ad), [#24138](https://github.com/open-webui/open-webui/issues/24138)
- 📄 **PDF text search restoration.** PDF previews now include a proper text layer so browser text selection and find-in-page search work again instead of rendering only image-like pages. [Commit](https://github.com/open-webui/open-webui/commit/bc4d6eef33dcb92719b07483cdb1d63ebf250721), [#24149](https://github.com/open-webui/open-webui/issues/24149)
- 🔑 **Android password autofill support.** Password inputs now expose the expected field name metadata, improving password manager autofill reliability on Android login pages. [Commit](https://github.com/open-webui/open-webui/commit/60ea4214aa42f1ad22142f1a43535007a2293d16), [#24137](https://github.com/open-webui/open-webui/issues/24137)
- 🎤 **Non-blocking STT processing.** Speech-to-text transcription no longer blocks the server event loop during both live transcription and uploaded audio file processing, so other users can continue using chats and live connections under concurrent load. [#24338](https://github.com/open-webui/open-webui/pull/24338), [#24379](https://github.com/open-webui/open-webui/pull/24379), [#24169](https://github.com/open-webui/open-webui/issues/24169)
- 🌐 **SearXNG language parameter handling.** Web searches now send clean multi-language values without trailing separators, so SearXNG requests no longer fail when multiple languages are selected. [Commit](https://github.com/open-webui/open-webui/commit/6dff85b9d205cfc4bc2845dac40909b8d859910c), [#24198](https://github.com/open-webui/open-webui/issues/24198)
- 📂 **File modal open-link behavior.** Clicking a file name in the file details modal now opens the correct file content in a new tab for uploaded file items instead of failing to open. [#24125](https://github.com/open-webui/open-webui/pull/24125)
- 📎 **Chat attachment display recovery.** Files attached by chat tools now appear reliably in assistant responses, including non-image file attachments that were previously hidden. [Commit](https://github.com/open-webui/open-webui/commit/7eeff2fdf945024585a01b72071a61971afc844d), [#24332](https://github.com/open-webui/open-webui/pull/24332)
- 🧱 **Channel embed rendering guard.** Channel message embeds now appear only for model-generated messages and are suppressed in reply previews, preventing unintended embed expansion in regular user posts. [Commit](https://github.com/open-webui/open-webui/commit/e1dce9914745de9b4d2c67b1deddde3472ce4dfa)
- 🛡️ **Safer image URL handling.** Untrusted external image URLs are now blocked in profile and rich-text image rendering paths, preventing unintended client-side requests to attacker-controlled domains. [#24420](https://github.com/open-webui/open-webui/pull/24420)
- 🛡️ **Sanitized spreadsheet HTML previews.** Spreadsheet previews now sanitize generated HTML before rendering, reducing the risk of unsafe content being executed when opening office files in chat and file modals. [#24468](https://github.com/open-webui/open-webui/pull/24468)
- 🧰 **Multi-worker tool update consistency.** Updated tool code now refreshes correctly across workers without requiring a full service restart, so chats no longer run stale tool versions after edits. [Commit](https://github.com/open-webui/open-webui/commit/3309f5d9f11f521c0ee97b64c59a83e3cf390bde), [#24400](https://github.com/open-webui/open-webui/issues/24400), [#24433](https://github.com/open-webui/open-webui/pull/24433)
- 🧩 **Default model metadata env parsing.** The "DEFAULT_MODEL_METADATA" environment setting is now parsed and applied correctly, including when persistent config is disabled, so configured model capability defaults are no longer ignored at startup. [Commit](https://github.com/open-webui/open-webui/commit/0103d7e82cccbd5c4b1c8daabcb3e5160fa74a97), [#24319](https://github.com/open-webui/open-webui/issues/24319)
- 🔄 **Config import and Redis consistency.** Imported settings now remain effective after import because configuration values are immediately synchronized to Redis, preventing stale cached values from overriding imported permissions and settings. [Commit](https://github.com/open-webui/open-webui/commit/55a572cd398c9b4e6118728f8f129941437aa225), [Commit](https://github.com/open-webui/open-webui/commit/1c1c8b18e5cc90ca3c6961a4c193a4363febbc83), [#24346](https://github.com/open-webui/open-webui/issues/24346)
- 🔔 **LDAP signup webhook parity.** New accounts created through LDAP now trigger the same signup webhook notifications as password and OAuth signups, so downstream provisioning and audit automations receive consistent events. [Commit](https://github.com/open-webui/open-webui/commit/fd3368c0bff168417e3c49ffd73491c344702339), [#24377](https://github.com/open-webui/open-webui/issues/24377)
- 🦆 **DDGS auto-backend compatibility.** Web search now handles DDGS automatic backend selection correctly and safely falls back on empty or rate-limited responses, preventing search failures in newer DDGS versions. [Commit](https://github.com/open-webui/open-webui/commit/9adc0c442a57eaa88a5f30c2b2cb393623154e20), [#24188](https://github.com/open-webui/open-webui/issues/24188)
- 🤖 **Automation update tool reliability.** Updating existing automations in chat now works correctly instead of failing with a missing method error. [Commit](https://github.com/open-webui/open-webui/commit/f39f4a86aedc2769d8268670a020b1f3c16776dd), [#24405](https://github.com/open-webui/open-webui/issues/24405#issuecomment-4408011166)
- 📅 **Calendar event permission checks.** Calendar event update and delete actions now handle ownership and access checks more reliably, returning clean access-denied results when appropriate. [Commit](https://github.com/open-webui/open-webui/commit/2977910ffd9d2369dfa504aa6ab12745b3dbd19a)
- 🛡️ **Safer cached file delivery.** Cached files that are not recognized as image, audio, or video now download as attachments instead of rendering inline, reducing the risk of unsafe browser content handling. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
- 📊 **Streaming token analytics accuracy.** Admin Analytics now records and aggregates token usage correctly for streaming chats across Responses API and OpenAI-compatible providers, including fallback handling for provider usage formats that use prompt and completion token keys. [Commit](https://github.com/open-webui/open-webui/commit/989d5fd4e2ce285edf4475a1e13f0981a78d3821), [Commit](https://github.com/open-webui/open-webui/commit/a32d26e61d24d9f63650faed5cb8909ed90af661), [#24217](https://github.com/open-webui/open-webui/issues/24217), [#24294](https://github.com/open-webui/open-webui/issues/24294), [#24241](https://github.com/open-webui/open-webui/issues/24241)
- 🔗 **Admin shared chat links.** Admin users can now open and clone shared chat links reliably without 401 errors because shared links are now resolved by share ID first, with safe fallback behavior for direct chat ID access. [Commit](https://github.com/open-webui/open-webui/commit/cde21b9f6dc11575a668484f42440824ec5a4fae), [#24311](https://github.com/open-webui/open-webui/issues/24311), [#24096](https://github.com/open-webui/open-webui/issues/24096)
- 💾 **Chat settings persistence.** System prompts and other chat-level settings now persist correctly after creating a new chat and reloading, preventing prompt loss in affected conversations. [Commit](https://github.com/open-webui/open-webui/commit/86df8bf27e1b84abbe2eeedcc8650df59c7d23d6), [#24193](https://github.com/open-webui/open-webui/issues/24193), [#24270](https://github.com/open-webui/open-webui/issues/24270)
- 💾 **Chat control autosave persistence.** Changes to chat controls like system prompt, parameters, and attached files are now autosaved on existing chats, so edits are no longer lost when you refresh or navigate away before sending a message. [Commit](https://github.com/open-webui/open-webui/commit/a938c8ae2e45a00d2f06151fdaeaee94e54a8095), [#23897](https://github.com/open-webui/open-webui/pull/23897)
- ☁️ **OneDrive option visibility.** OneDrive personal and business upload options now appear only when their respective client IDs are configured, preventing unavailable options from showing in attachment menus. [Commit](https://github.com/open-webui/open-webui/commit/b72019db393a658ca0ceecdcc59b70f6cc5dcd40), [#24411](https://github.com/open-webui/open-webui/issues/24411)
- 🧠 **Reasoning content leakage prevention.** Tool-call round-trip messages no longer wrap reasoning text in `<think>` tags inside the content field, preventing raw markup from leaking into chat output for models whose templates don't strip think tags (e.g. Gemma 4). [#23844](https://github.com/open-webui/open-webui/issues/23844)
- 🖥️ **Terminal sidebar auto-open guard.** The terminal sidebar no longer auto-opens on chat load when OpenTerminal is disabled, because stale terminal IDs saved on models or in localStorage are now validated against available terminal servers before use.
- 🔁 **Single-confirmation connection deletion.** Deleting OpenAI, Ollama, tool server, and terminal server connections now shows exactly one confirmation dialog instead of two, because redundant outer confirmation wrappers were removed from all connection components.
- 🧵 **Reliable background task cleanup.** The chat task lifecycle now deregisters completed tasks before checking for remaining siblings, eliminating the off-by-one timing issue that could leave the stop button stuck or dismiss the sidebar activity spinner too early.
### 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.
- 🚪 **Signout request method.** The signout endpoint now requires POST instead of GET, so custom clients and integrations must update logout calls accordingly. [#24420](https://github.com/open-webui/open-webui/pull/24420)
## [0.9.2] - 2026-04-24
### Added
- 🧠 **PaddleOCR-vl document extraction.** Administrators can now use PaddleOCR-vl as a content extraction engine for document processing, with configurable API URL and token settings in document retrieval configuration. [#23945](https://github.com/open-webui/open-webui/pull/23945)
- 🔥 **Firecrawl v2 API.** Firecrawl web loading now uses the v2 API directly with proper retry logic, exponential backoff on rate limits, and configurable timeout handling, improving reliability for both cloud and self-hosted Firecrawl setups. [#23934](https://github.com/open-webui/open-webui/pull/23934)
- ⏰ **Calendar event reminder customization.** Calendar events now support a configurable `reminder_minutes` parameter, allowing models to set custom reminder durations instead of the default 10-minute notification.
- 🔑 **Custom API key header.** Administrators can now configure a custom header name for API key authentication via the `CUSTOM_API_KEY_HEADER` environment variable, enabling compatibility with reverse proxies that use the `Authorization` header for their own authentication.
- 🔌 **OAuth session disconnection.** Users can now disconnect OAuth sessions for specific providers (e.g., MCP connections) through a new API endpoint, enabling cleaner re-authentication workflows.
- 📚 **Source overflow indicator.** The Sources button now shows a +N badge when more than three sources are available, so hidden sources are clearly indicated in chat responses. [#23918](https://github.com/open-webui/open-webui/pull/23918)
- ⚡ **Model list performance.** Model list API responses now strip base64 profile image data from paginated results, and model tags are fetched via a dedicated efficient query instead of loading all models. This significantly reduces payload sizes and improves workspace Models page responsiveness.
- ⚡ **Model avatar cache reuse.** Default model profile images now redirect to a shared static path instead of reading files from disk per-request, reducing repeated I/O and improving loading efficiency when multiple models use the fallback icon. [#24015](https://github.com/open-webui/open-webui/pull/24015)
- 🚀 **Faster splash image loading.** Splash screen images are now prioritized earlier during page load with preload links, improving first-load LCP behavior and reducing delayed image discovery. [#24011](https://github.com/open-webui/open-webui/pull/24011)
- 🧵 **Streaming markdown performance stability.** Streaming responses now stay more memory-efficient by preventing repeated cleanup callback registration during markdown updates. [#24048](https://github.com/open-webui/open-webui/pull/24048)
- 📊 **Telemetry gauge reliability.** OpenTelemetry user gauge callbacks now use synchronous database queries directly, eliminating cross-thread async bridging issues that could cause silent failures in metric collection.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Finnish, Korean, Portuguese (Brazil), and Dutch were enhanced and expanded.
### Fixed
- 🔧 **MCP task cancellation stability.** Interrupted MCP tool calls no longer cause CPU spikes or runaway cleanup behavior. MCP client disconnection now runs in the same asyncio task as connection, respecting cancel scope constraints, and chat-active events are properly shielded during cancellation.
- 🧠 **Persistent chat skill injection.** Skills mentioned in persisted chats now inject into the system prompt reliably. Skill ID extraction from `<$skillId|label>` message tags is now handled server-side, and tags are stripped before messages reach the model.
- 🗄️ **Async database driver migration.** The async database backend now uses psycopg (v3) instead of asyncpg, eliminating brittle SSL parameter translation and supporting native libpq connection strings including `sslmode`, `options`, and `target_session_attrs` without any stripping or conversion.
- 🐳 **Docker ARM64 reliability.** Docker images built for arm64 via QEMU cross-compilation no longer produce 0-byte corrupted Python dependencies. `UV_LINK_MODE=copy` is now set in the Dockerfile to force reliable file installation.
- 🛠️ **Throttle request handling.** Request handling no longer fails when user activity status updates are throttled with a non-zero interval. [#23979](https://github.com/open-webui/open-webui/pull/23979)
- ✍️ **Rich text extension conflicts.** Rich text editing no longer triggers duplicate extension conflicts for lists and code blocks, improving editor stability. [#24009](https://github.com/open-webui/open-webui/pull/24009)
- 🔇 **Fetch URL null content guard.** The `fetch_url` built-in tool now safely handles `None` content returned by web loaders instead of crashing with a `TypeError`.
- 🌐 **OAuth discovery fallback.** OAuth protected resource discovery now falls back to well-known RFC 9728 URIs when the `WWW-Authenticate` header doesn't contain a `resource_metadata` link, improving compatibility with more MCP server implementations.
- 🔐 **Session token resolution.** Session user endpoints now gracefully handle missing `Authorization` headers by falling back to cookie and request state tokens, preventing errors when used behind forward-auth proxies.
- 🚫 **Direct API error responses.** Chat completion requests without a WebSocket channel (direct API calls) now return proper HTTP error responses instead of silently returning null on failure.
- 📡 **Cancelled response stream cleanup.** Cancelled chat generation now explicitly closes the upstream response body iterator, preventing orphaned async generators from spinning in anyio internals.
- 🔒 **Model profile image path safety.** Model profile image endpoints now validate and sanitize static asset redirect paths, preventing path traversal through encoded dots or malicious URL patterns.
- 📊 **RAG template validation UI.** The Documents settings page now displays a warning when RAG templates contain multiple `[context]` or `{{CONTEXT}}` placeholders, helping administrators avoid accidental redundant context injection.
- 🧩 **Automation model detection.** The `create_automation` tool now correctly detects the current model ID even when `model_id` is not yet set in metadata, falling back to the model dict.
- 🔄 **MCP resource content handling.** MCP tool results with the `resource` content type are now correctly detected and their `resource.text` payload is extracted, instead of being silently ignored.
- 🔄 **Ollama and OpenAI metadata forwarding.** Ollama and OpenAI proxy routes now forward request metadata to downstream handlers, ensuring consistent context propagation.
- 🧹 **Browser-native message virtualization.** The custom JavaScript-based message culling system (spacers, height caching, scroll listeners) was replaced with CSS `content-visibility: auto`, letting the browser natively skip rendering of off-screen messages without destroying component trees. This eliminates scroll jump artifacts and mount/destroy thrashing while preserving memory efficiency in long conversations.
- 📻 **Redis notification compatibility.** Redis pub/sub now handles missing or incompatible `client_name` support more gracefully, preventing connection errors with certain Redis configurations.
### Changed
- ⚙️ **psycopg v3 async driver.** The async database driver has been migrated from `asyncpg` to `psycopg` (v3). This is a transparent change for most deployments, but custom connection strings with `asyncpg`-specific parameters may need adjustment.
- 🔑 **Brotli dependency update.** Brotli has been updated to address CVE-2025-6176.
- 🖥️ **Windows startup script.** The Windows startup batch script has been updated for improved compatibility.
## [0.9.1] - 2026-04-21
### Fixed
+5 -2
View File
@@ -43,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
RUN npm run build
######## WebUI backend ########
FROM python:3.11.14-slim-bookworm AS base
FROM python:3.11-slim-bookworm AS base
# Use args
ARG USE_CUDA
@@ -126,7 +126,7 @@ RUN chown -R $UID:$GID /app $HOME
# Install common system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git build-essential pandoc gcc netcat-openbsd curl jq \
git build-essential pandoc gcc netcat-openbsd curl jq ca-certificates \
libmariadb-dev \
python3-dev \
ffmpeg libsm6 libxext6 zstd \
@@ -135,6 +135,9 @@ RUN apt-get update && \
# install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds
ENV UV_LINK_MODE=copy
RUN set -e; \
pip3 install --no-cache-dir uv; \
if [ "$USE_CUDA" = "true" ]; then \
+56 -26
View File
@@ -8,7 +8,7 @@
![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui)
![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red)
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/open-webui)
![Open WebUI Banner](./banner.png)
@@ -27,58 +27,84 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
## Key Features of Open WebUI ⭐
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments.
- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**.
- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely.
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group.
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more.
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/).
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection.
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control.
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
- ✅ **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling.
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced.
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost.
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI).
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command.
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation.
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed.
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing.
- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
- ⚙️ **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards.
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
- 🗄️ **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace.
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage.
- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack.
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers.
- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage!
- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features.
- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security).
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
## The Open WebUI Ecosystem 🌐
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.
- 🔒 **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.
- 🔄 **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.
- 🖥️ **Native Desktop App** ([open-webui/desktop](https://github.com/open-webui/desktop)): Run Open WebUI as a native app on macOS, Windows, and Linux. System-wide Spotlight chat bar with screenshot capture, push-to-talk voice, and optional fully-local inference via a built-in llama.cpp engine.
Want to learn more? Check out our [Open WebUI documentation](https://docs.openwebui.com) for more details!
---
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
@@ -222,6 +248,10 @@ This project contains code under multiple licenses. The current codebase include
If you have any questions, suggestions, or need assistance, please open an issue or join our
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
## Security 🛡️
If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure!
## Star History
<a href="https://star-history.com/#open-webui/open-webui&Date">
+12 -1
View File
@@ -1,6 +1,7 @@
import base64
import os
import random
import sys
from pathlib import Path
from typing import Annotated
@@ -10,6 +11,7 @@ import uvicorn
app = typer.Typer()
KEY_FILE = Path.cwd() / '.webui_secret_key'
DEFAULT_SECRET_KEY_LENGTH = 24
def version_callback(value: bool) -> None:
@@ -36,8 +38,11 @@ def serve(
if os.getenv('WEBUI_SECRET_KEY') is None:
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
if not KEY_FILE.exists():
key_length = int(os.getenv('WEBUI_SECRET_KEY_LENGTH', DEFAULT_SECRET_KEY_LENGTH))
if key_length < 1:
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
@@ -68,12 +73,18 @@ def serve(
import open_webui.main # noqa: F401
from open_webui.env import UVICORN_WORKERS # Import the workers setting
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
# which is incompatible with psycopg v3 async. Setting loop='none' lets
# asyncio.run() respect the WindowsSelectorEventLoopPolicy set in db.py.
loop = 'none' if sys.platform == 'win32' else 'auto'
uvicorn.run(
'open_webui.main:app',
host=host,
port=port,
forwarded_allow_ips='*',
workers=UVICORN_WORKERS,
loop=loop,
)
+2097 -3126
View File
File diff suppressed because it is too large Load Diff
+27 -4
View File
@@ -1,6 +1,29 @@
from __future__ import annotations
import errno
from enum import Enum
_ERRNO_MESSAGES = {
errno.ENAMETOOLONG: 'File name is too long.',
errno.ENOSPC: 'The server is out of storage space.',
errno.EDQUOT: 'Server storage quota exceeded.',
errno.EACCES: 'Server storage is not writable.',
errno.EPERM: 'Server storage is not writable.',
errno.EROFS: 'Server storage is not writable.',
}
def _error_message(err='', fallback='') -> str:
if not err:
return 'Something went wrong :/'
if isinstance(err, OSError) and err.errno in _ERRNO_MESSAGES:
return f'[ERROR: {_ERRNO_MESSAGES[err.errno]}]'
if isinstance(err, Exception):
return f'[ERROR: {fallback}]' if fallback else 'Something went wrong :/'
return f'[ERROR: {err}]'
class MESSAGES(str, Enum):
DEFAULT = lambda msg='': f'{msg if msg else ""}'
MODEL_ADDED = lambda model='': f"The model '{model}' has been added successfully."
@@ -16,7 +39,7 @@ class ERROR_MESSAGES(str, Enum):
def __str__(self) -> str:
return super().__str__()
DEFAULT = lambda err='': f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
DEFAULT = _error_message
ENV_VAR_NOT_FOUND = 'Required environment variable not found. Terminating now.'
CREATE_USER_ERROR = 'Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance.'
DELETE_USER_ERROR = 'Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot.'
@@ -72,11 +95,11 @@ class ERROR_MESSAGES(str, Enum):
EMPTY_CONTENT = 'The content provided is empty. Please ensure that there is text or data present before proceeding.'
DB_NOT_SQLITE = 'This feature is only available when running with SQLite databases.'
DB_NOT_SQLITE = 'This feature is only available with SQLite databases.'
INVALID_URL = 'Oops! The URL you provided is invalid. Please double-check and try again.'
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
WEB_SEARCH_ERROR = lambda err='': f'{err if err else "Oops! Something went wrong while searching the web."}'
WEB_SEARCH_ERROR = lambda err='': err if err else 'Something went wrong while searching the web.'
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
+554 -459
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -27
View File
@@ -1,11 +1,10 @@
import logging
import sys
import asyncio
import inspect
import json
import asyncio
from pydantic import BaseModel
import logging
import sys
from typing import AsyncGenerator, Generator, Iterator
from fastapi import (
Depends,
FastAPI,
@@ -16,40 +15,35 @@ from fastapi import (
UploadFile,
status,
)
from pydantic import BaseModel
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.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel
from open_webui.socket.main import (
get_event_call,
get_event_emitter,
)
from open_webui.models.users import UserModel
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.access_control import check_model_access
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.utils.misc import (
add_or_update_system_message,
get_last_user_message,
prepend_to_first_user_message_content,
openai_chat_chunk_message_template,
openai_chat_completion_message_template,
prepend_to_first_user_message_content,
)
from open_webui.utils.payload import (
apply_model_params_to_body_openai,
apply_system_prompt_to_body,
)
from open_webui.utils.plugin import (
get_function_module_from_cache,
load_function_module_by_id,
)
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@@ -284,7 +278,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
if params:
system = params.pop('system', None)
form_data = apply_model_params_to_body_openai(params, form_data)
form_data = apply_system_prompt_to_body(system, form_data, metadata, user)
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
pipe_id = get_pipe_id(form_data)
function_module = await get_function_module_by_id(request, pipe_id)
@@ -324,11 +318,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
async for line in res:
yield process_line(form_data, line)
if isinstance(res, str) or isinstance(res, Generator):
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
finish_message['choices'][0]['finish_reason'] = 'stop'
yield f'data: {json.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
finish_message['choices'][0]['finish_reason'] = 'stop'
yield f'data: {json.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
return StreamingResponse(stream_content(), media_type='text/event-stream')
else:
+185 -156
View File
@@ -1,210 +1,233 @@
import os
from __future__ import annotations
import json
import logging
import ssl as _stdlib_ssl
import os
import sys
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from open_webui.internal.wrappers import register_connection
from open_webui.env import (
OPEN_WEBUI_DIR,
DATABASE_URL,
DATABASE_SCHEMA,
DATABASE_ENABLE_IAM_TOKEN_AUTH,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_POOL_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SCHEMA,
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
DATABASE_URL,
ENABLE_DB_MIGRATIONS,
OPEN_WEBUI_DIR,
)
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import Dialect, MetaData, create_engine, event, types
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker, Session
from sqlalchemy.pool import QueuePool, NullPool
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from sqlalchemy.pool import NullPool, QueuePool
from sqlalchemy.sql.type_api import _T
from typing_extensions import Self
log = logging.getLogger(__name__)
def extract_ssl_mode_from_url(url: str) -> tuple[str, str | None]:
# ── SSL URL normalization (used by sync engine & Alembic migrations) ─
#
# psycopg2 (sync) needs ``sslmode=`` in the connection string (it does
# not recognise the bare ``ssl=`` key that some ORMs emit). The helpers
# below strip all SSL-related query params, normalise them, and
# reattach them in the canonical libpq form.
#
# The **async** engine now uses psycopg (v3), which speaks libpq
# natively, so it needs no translation at all — the DATABASE_URL is
# passed through as-is.
# ─────────────────────────────────────────────────────────────────────
def _pop_first(params: dict[str, list[str]], key: str) -> str | None:
"""Pop a single-valued query param, returning ``None`` if absent."""
values = params.pop(key, None)
return values[0] if values else None
def _is_postgres_url(url: str) -> bool:
"""Return True if *url* looks like a PostgreSQL connection string."""
return bool(url) and any(url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://'))
def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]:
"""Strip SSL query-string parameters from a PostgreSQL URL.
asyncpg and psycopg2 use different query-string keys for SSL
(``ssl`` vs ``sslmode``). This helper removes **both** from the
URL so that each driver can receive the correct parameter through
its own mechanism (query-string re-injection for psycopg2,
``connect_args`` for asyncpg).
Returns
-------
(url_without_ssl, ssl_mode)
*url_without_ssl* is the original URL with ``ssl`` / ``sslmode``
query parameters removed. *ssl_mode* is the extracted mode
string (e.g. ``'require'``), or ``None`` if neither parameter
was present.
Non-PostgreSQL URLs are returned unchanged with ``ssl_mode=None``.
Returns ``(url_without_ssl, ssl_dict)`` where *ssl_dict* maps
canonical libpq key names (``sslmode``, ``sslrootcert``, …) to
their values. Non-PostgreSQL URLs are returned unchanged with an
empty dict.
"""
if not url or not any(url.startswith(prefix) for prefix in ('postgresql://', 'postgresql+', 'postgres://')):
return url, None
if not _is_postgres_url(url):
return url, {}
parsed = urlparse(url)
query_params = parse_qs(parsed.query, keep_blank_values=True)
qp = parse_qs(parsed.query, keep_blank_values=True)
# Prefer sslmode (libpq canonical) over the asyncpg-only ssl key.
ssl_mode: str | None = None
for key in ('sslmode', 'ssl'):
values = query_params.pop(key, None)
if values and ssl_mode is None:
ssl_mode = values[0]
# Prefer sslmode (libpq canonical) over the bare ``ssl`` key.
sslmode_val = _pop_first(qp, 'sslmode')
ssl_val = _pop_first(qp, 'ssl')
ssl_mode = sslmode_val or ssl_val
if ssl_mode is None:
# Nothing to strip — return the URL untouched.
return url, None
ssl_dict: dict[str, str] = {}
if ssl_mode:
ssl_dict['sslmode'] = ssl_mode
for key in ('sslrootcert', 'sslcert', 'sslkey', 'sslcrl'):
val = _pop_first(qp, key)
if val:
ssl_dict[key] = val
# Rebuild the query string without the SSL keys.
remaining_query = urlencode(query_params, doseq=True)
url_without_ssl = urlunparse(parsed._replace(query=remaining_query))
return url_without_ssl, ssl_mode
if not ssl_dict:
return url, ssl_dict
cleaned_query = urlencode(qp, doseq=True)
return urlunparse(parsed._replace(query=cleaned_query)), ssl_dict
def build_asyncpg_ssl_args(ssl_mode: str | None) -> dict:
"""Convert a libpq-style SSL mode value to asyncpg ``connect_args``.
def reattach_ssl_params_to_url(url_without_ssl: str, ssl_dict: dict[str, str]) -> str:
"""Re-append SSL query-string parameters to a cleaned PostgreSQL URL.
Returns a dict suitable for unpacking into
``create_async_engine(..., connect_args=...)``.
Used for psycopg2/libpq consumers that expect ``sslmode`` and the
certificate-file keys in the connection string.
"""
if ssl_mode is None:
return {}
mode = ssl_mode.lower()
if mode == 'disable':
return {'connect_args': {'ssl': False}}
if mode in ('allow', 'prefer'):
# asyncpg has no direct equivalent — omit to let it try without.
return {}
if mode == 'require':
# SSL required but no certificate verification (matches libpq).
ctx = _stdlib_ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _stdlib_ssl.CERT_NONE
return {'connect_args': {'ssl': ctx}}
if mode in ('verify-ca', 'verify-full'):
# Full verification — use the system trust store.
ctx = _stdlib_ssl.create_default_context()
if mode == 'verify-ca':
ctx.check_hostname = False
return {'connect_args': {'ssl': ctx}}
# Unknown value — pass through as-is and let asyncpg decide.
return {'connect_args': {'ssl': ssl_mode}}
def reattach_ssl_mode_to_url(url_without_ssl: str, ssl_mode: str | None) -> str:
"""Re-append ``sslmode=<value>`` to a cleaned PostgreSQL URL.
Used for psycopg2 / libpq consumers that expect the canonical
``sslmode`` query-string key.
"""
if ssl_mode is None:
if not ssl_dict:
return url_without_ssl
separator = '&' if '?' in url_without_ssl else '?'
return f'{url_without_ssl}{separator}sslmode={ssl_mode}'
parts = [f'{k}={v}' for k, v in ssl_dict.items() if v]
if not parts:
return url_without_ssl
sep = '&' if '?' in url_without_ssl else '?'
return f'{url_without_ssl}{sep}{"&".join(parts)}'
class JSONField(types.TypeDecorator):
impl = types.Text
# Backwards-compatible aliases for external callers.
extract_ssl_mode_from_url = extract_ssl_params_from_url
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
"""Store arbitrary Python objects as JSON-encoded TEXT.
Used instead of native JSON columns for portability across SQLite and
PostgreSQL. Values are serialized with ``json.dumps`` on write and
deserialized with ``json.loads`` on read.
"""
impl = types.UnicodeText
cache_ok = True
def process_bind_param(self, value: Optional[_T], dialect: Dialect) -> Any:
return json.dumps(value)
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
return json.dumps(value) if value is not None else None
def process_result_value(self, value: Optional[_T], dialect: Dialect) -> Any:
if value is not None:
return json.loads(value)
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
return json.loads(value) if value is not None else None
def copy(self, **kw: Any) -> Self:
return JSONField(self.impl.length)
def db_value(self, value):
return json.dumps(value)
def python_value(self, value):
if value is not None:
return json.loads(value)
def copy(self, **kwargs: Any) -> Self:
return JSONField(length=self.impl.length)
# Workaround to handle the peewee migration
# This is required to ensure the peewee migration is handled before the alembic migration
def handle_peewee_migration(DATABASE_URL):
db = None
try:
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`).
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DATABASE_URL)
normalized_url = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode)
# Normalize SSL params from the URL once; the sync engine needs them
# reattached in canonical libpq form for psycopg2.
_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
# Replace the postgresql:// with postgres:// to handle the peewee migration
db = register_connection(normalized_url.replace('postgresql://', 'postgres://'))
migrate_dir = OPEN_WEBUI_DIR / 'internal' / 'migrations'
router = Router(db, logger=log, migrate_dir=migrate_dir)
router.run()
db.close()
except Exception as e:
log.error(f'Failed to initialize the database connection: {e}')
log.warning('Hint: If your database password contains special characters, you may need to URL-encode it.')
raise
finally:
# Properly closing the database connection
if db and not db.is_closed():
db.close()
# Assert if db connection has been closed
if db is not None:
assert db.is_closed(), 'Database connection is still open.'
# For psycopg2 (sync engine), re-append sslmode + cert-file params.
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
class RDSIAMTokenAuth:
_refresh_after = timedelta(minutes=14)
def __init__(self, database_url: str) -> None:
url = make_url(database_url)
if not url.drivername.startswith(('postgresql', 'postgres')):
raise ValueError('DATABASE_ENABLE_IAM_TOKEN_AUTH is only supported for PostgreSQL databases')
if not url.host or not url.username:
raise ValueError('DATABASE_ENABLE_IAM_TOKEN_AUTH requires a database host and user')
self.host = url.host
self.port = url.port or 5432
self.username = url.username
self._client = None
self._token: str | None = None
self._expires_at = datetime.min.replace(tzinfo=timezone.utc)
@property
def client(self):
if self._client is None:
import boto3
self._client = boto3.client('rds')
return self._client
def get_password(self) -> str:
now = datetime.now(timezone.utc)
if self._token and now < self._expires_at:
return self._token
self._token = self.client.generate_db_auth_token(
DBHostname=self.host,
Port=self.port,
DBUsername=self.username,
)
self._expires_at = now + self._refresh_after
log.info('AWS RDS IAM database token refreshed; next refresh after %s', self._expires_at.isoformat())
return self._token
# Normalize SSL params from the URL once; each engine branch re-injects
# the driver-appropriate form.
DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE = extract_ssl_mode_from_url(DATABASE_URL)
_rds_iam_token_auth = RDSIAMTokenAuth(SQLALCHEMY_DATABASE_URL) if DATABASE_ENABLE_IAM_TOKEN_AUTH else None
# For psycopg2 (sync engine), re-append sslmode=<value>.
SQLALCHEMY_DATABASE_URL = (
reattach_ssl_mode_to_url(DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE) if DATABASE_SSL_MODE else DATABASE_URL
)
def _set_iam_token_password(dialect, conn_rec, cargs, cparams):
if _rds_iam_token_auth is not None:
cparams['password'] = _rds_iam_token_auth.get_password()
def enable_iam_token_auth(connectable) -> None:
if _rds_iam_token_auth is None:
return
engine = getattr(connectable, 'sync_engine', connectable)
if not event.contains(engine, 'do_connect', _set_iam_token_password):
event.listen(engine, 'do_connect', _set_iam_token_password)
def _make_async_url(url: str) -> str:
"""Convert a sync database URL to its async driver equivalent."""
"""Convert a sync database URL to its async driver equivalent.
The async engine uses psycopg (v3) which speaks libpq natively,
so all standard connection-string parameters (``sslmode``,
``options``, ``target_session_attrs``, etc.) are passed through
without any translation.
"""
if url.startswith('sqlite+sqlcipher://'):
# SQLCipher has no async driver — not supported for async
raise ValueError(
'sqlite+sqlcipher:// URLs are not supported with async engine. '
'Use standard sqlite:// or postgresql:// instead.'
)
if url.startswith('sqlite:///') or url.startswith('sqlite://'):
return url.replace('sqlite://', 'sqlite+aiosqlite://', 1)
# psycopg v3 — auto-selects async mode with create_async_engine
if url.startswith('postgresql+psycopg2://'):
return url.replace('postgresql+psycopg2://', 'postgresql+asyncpg://', 1)
return url.replace('postgresql+psycopg2://', 'postgresql+psycopg://', 1)
if url.startswith('postgresql://'):
return url.replace('postgresql://', 'postgresql+asyncpg://', 1)
return url.replace('postgresql://', 'postgresql+psycopg://', 1)
if url.startswith('postgres://'):
return url.replace('postgres://', 'postgresql+asyncpg://', 1)
return url.replace('postgres://', 'postgresql+psycopg://', 1)
# For other dialects, return as-is and let SQLAlchemy handle it
return url
@@ -305,6 +328,8 @@ else:
else:
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
enable_iam_token_auth(engine)
# Sync session — used ONLY for startup config loading (config.py runs at import time)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
@@ -329,10 +354,19 @@ get_db = contextmanager(get_session)
# ASYNC ENGINE (used for ALL runtime database operations)
# ============================================================
# Use the SSL-stripped URL for asyncpg — SSL is injected via connect_args.
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(
DATABASE_URL_WITHOUT_SSL if DATABASE_SSL_MODE else SQLALCHEMY_DATABASE_URL
)
# psycopg (v3) speaks libpq natively — the full DATABASE_URL is passed
# through as-is. SSL params, ``options``, ``target_session_attrs``, etc.
# all work without any stripping or translation.
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
# psycopg v3 cannot run in async mode under Windows' default
# ProactorEventLoop — switch to SelectorEventLoop before creating
# the async engine. This runs at import time, which is early enough
# to cover every entry point (workers, reload, direct invocations).
if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
# Generous default — async coroutines + no session sharing = high connection demand.
@@ -350,10 +384,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
def _set_sqlite_pragmas(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
else:
# Inject asyncpg-compatible SSL connect_args when the user specified
# sslmode/ssl in DATABASE_URL.
asyncpg_ssl_args = build_asyncpg_ssl_args(DATABASE_SSL_MODE)
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
async_engine = create_async_engine(
@@ -363,22 +393,21 @@ else:
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
**asyncpg_ssl_args,
)
else:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
poolclass=NullPool,
**asyncpg_ssl_args,
)
else:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
**asyncpg_ssl_args,
)
enable_iam_token_auth(async_engine)
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
@@ -409,7 +438,7 @@ async def get_async_db():
@asynccontextmanager
async def get_async_db_context(db: Optional[AsyncSession] = None):
async def get_async_db_context(db: AsyncSession | None = None):
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
yield db
@@ -1,253 +0,0 @@
"""Peewee migrations -- 001_initial_schema.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# We perform different migrations for SQLite and other databases
# This is because SQLite is very loose with enforcing its schema, and trying to migrate other databases like SQLite
# will require per-database SQL queries.
# Instead, we assume that because external DB support was added at a later date, it is safe to assume a newer base
# schema instead of trying to migrate from an older schema.
if isinstance(database, pw.SqliteDatabase):
migrate_sqlite(migrator, database, fake=fake)
else:
migrate_external(migrator, database, fake=fake)
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Auth(pw.Model):
id = pw.CharField(max_length=255, unique=True)
email = pw.CharField(max_length=255)
password = pw.CharField(max_length=255)
active = pw.BooleanField()
class Meta:
table_name = 'auth'
@migrator.create_model
class Chat(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.CharField()
chat = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chat'
@migrator.create_model
class ChatIdTag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
tag_name = pw.CharField(max_length=255)
chat_id = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chatidtag'
@migrator.create_model
class Document(pw.Model):
id = pw.AutoField()
collection_name = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255, unique=True)
title = pw.CharField()
filename = pw.CharField()
content = pw.TextField(null=True)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'document'
@migrator.create_model
class Modelfile(pw.Model):
id = pw.AutoField()
tag_name = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
modelfile = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'modelfile'
@migrator.create_model
class Prompt(pw.Model):
id = pw.AutoField()
command = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.CharField()
content = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'prompt'
@migrator.create_model
class Tag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
data = pw.TextField(null=True)
class Meta:
table_name = 'tag'
@migrator.create_model
class User(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
email = pw.CharField(max_length=255)
role = pw.CharField(max_length=255)
profile_image_url = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'user'
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Auth(pw.Model):
id = pw.CharField(max_length=255, unique=True)
email = pw.CharField(max_length=255)
password = pw.TextField()
active = pw.BooleanField()
class Meta:
table_name = 'auth'
@migrator.create_model
class Chat(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.TextField()
chat = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chat'
@migrator.create_model
class ChatIdTag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
tag_name = pw.CharField(max_length=255)
chat_id = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chatidtag'
@migrator.create_model
class Document(pw.Model):
id = pw.AutoField()
collection_name = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255, unique=True)
title = pw.TextField()
filename = pw.TextField()
content = pw.TextField(null=True)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'document'
@migrator.create_model
class Modelfile(pw.Model):
id = pw.AutoField()
tag_name = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
modelfile = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'modelfile'
@migrator.create_model
class Prompt(pw.Model):
id = pw.AutoField()
command = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.TextField()
content = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'prompt'
@migrator.create_model
class Tag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
data = pw.TextField(null=True)
class Meta:
table_name = 'tag'
@migrator.create_model
class User(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
email = pw.CharField(max_length=255)
role = pw.CharField(max_length=255)
profile_image_url = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'user'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('user')
migrator.remove_model('tag')
migrator.remove_model('prompt')
migrator.remove_model('modelfile')
migrator.remove_model('document')
migrator.remove_model('chatidtag')
migrator.remove_model('chat')
migrator.remove_model('auth')
@@ -1,45 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('chat', share_id=pw.CharField(max_length=255, null=True, unique=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('chat', 'share_id')
@@ -1,45 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('user', api_key=pw.CharField(max_length=255, null=True, unique=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('user', 'api_key')
@@ -1,45 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('chat', archived=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('chat', 'archived')
@@ -1,125 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
if isinstance(database, pw.SqliteDatabase):
migrate_sqlite(migrator, database, fake=fake)
else:
migrate_external(migrator, database, fake=fake)
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
'chat',
created_at=pw.DateTimeField(null=True), # Allow null for transition
updated_at=pw.DateTimeField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('chat', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'chat',
created_at=pw.DateTimeField(null=False),
updated_at=pw.DateTimeField(null=False),
)
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
'chat',
created_at=pw.BigIntegerField(null=True), # Allow null for transition
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('chat', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'chat',
created_at=pw.BigIntegerField(null=False),
updated_at=pw.BigIntegerField(null=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
if isinstance(database, pw.SqliteDatabase):
rollback_sqlite(migrator, database, fake=fake)
else:
rollback_external(migrator, database, fake=fake)
def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('chat', timestamp=pw.DateTimeField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE chat SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('chat', 'created_at', 'updated_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('chat', timestamp=pw.DateTimeField(null=False))
def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False):
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('chat', timestamp=pw.BigIntegerField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE chat SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('chat', 'created_at', 'updated_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('chat', timestamp=pw.BigIntegerField(null=False))
@@ -1,129 +0,0 @@
"""Peewee migrations -- 006_migrate_timestamps_and_charfields.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Alter the tables with timestamps
migrator.change_fields(
'chatidtag',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'document',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'modelfile',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'prompt',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'user',
timestamp=pw.BigIntegerField(),
)
# Alter the tables with varchar to text where necessary
migrator.change_fields(
'auth',
password=pw.TextField(),
)
migrator.change_fields(
'chat',
title=pw.TextField(),
)
migrator.change_fields(
'document',
title=pw.TextField(),
filename=pw.TextField(),
)
migrator.change_fields(
'prompt',
title=pw.TextField(),
)
migrator.change_fields(
'user',
profile_image_url=pw.TextField(),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
if isinstance(database, pw.SqliteDatabase):
# Alter the tables with timestamps
migrator.change_fields(
'chatidtag',
timestamp=pw.DateField(),
)
migrator.change_fields(
'document',
timestamp=pw.DateField(),
)
migrator.change_fields(
'modelfile',
timestamp=pw.DateField(),
)
migrator.change_fields(
'prompt',
timestamp=pw.DateField(),
)
migrator.change_fields(
'user',
timestamp=pw.DateField(),
)
migrator.change_fields(
'auth',
password=pw.CharField(max_length=255),
)
migrator.change_fields(
'chat',
title=pw.CharField(),
)
migrator.change_fields(
'document',
title=pw.CharField(),
filename=pw.CharField(),
)
migrator.change_fields(
'prompt',
title=pw.CharField(),
)
migrator.change_fields(
'user',
profile_image_url=pw.CharField(),
)
@@ -1,78 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields created_at and updated_at to the 'user' table
migrator.add_fields(
'user',
created_at=pw.BigIntegerField(null=True), # Allow null for transition
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
last_active_at=pw.BigIntegerField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql(
'UPDATE "user" SET created_at = timestamp, updated_at = timestamp, last_active_at = timestamp WHERE timestamp IS NOT NULL'
)
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('user', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'user',
created_at=pw.BigIntegerField(null=False),
updated_at=pw.BigIntegerField(null=False),
last_active_at=pw.BigIntegerField(null=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('user', timestamp=pw.BigIntegerField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE "user" SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('user', 'created_at', 'updated_at', 'last_active_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('user', timestamp=pw.BigIntegerField(null=False))
@@ -1,52 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Memory(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
content = pw.TextField(null=False)
updated_at = pw.BigIntegerField(null=False)
created_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'memory'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('memory')
@@ -1,60 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Model(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
base_model_id = pw.TextField(null=True)
name = pw.TextField()
meta = pw.TextField()
params = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'model'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('model')
@@ -1,130 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
import json
from open_webui.utils.misc import parse_ollama_modelfile
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Fetch data from 'modelfile' table and insert into 'model' table
migrate_modelfile_to_model(migrator, database)
# Drop the 'modelfile' table
migrator.remove_model('modelfile')
def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database):
ModelFile = migrator.orm['modelfile']
Model = migrator.orm['model']
modelfiles = ModelFile.select()
for modelfile in modelfiles:
# Extract and transform data in Python
modelfile.modelfile = json.loads(modelfile.modelfile)
meta = json.dumps(
{
'description': modelfile.modelfile.get('desc'),
'profile_image_url': modelfile.modelfile.get('imageUrl'),
'ollama': {'modelfile': modelfile.modelfile.get('content')},
'suggestion_prompts': modelfile.modelfile.get('suggestionPrompts'),
'categories': modelfile.modelfile.get('categories'),
'user': {**modelfile.modelfile.get('user', {}), 'community': True},
}
)
info = parse_ollama_modelfile(modelfile.modelfile.get('content'))
# Insert the processed data into the 'model' table
Model.create(
id=f'ollama-{modelfile.tag_name}',
user_id=modelfile.user_id,
base_model_id=info.get('base_model_id'),
name=modelfile.modelfile.get('title'),
meta=meta,
params=json.dumps(info.get('params', {})),
created_at=modelfile.timestamp,
updated_at=modelfile.timestamp,
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
recreate_modelfile_table(migrator, database)
move_data_back_to_modelfile(migrator, database)
migrator.remove_model('model')
def recreate_modelfile_table(migrator: Migrator, database: pw.Database):
query = """
CREATE TABLE IF NOT EXISTS modelfile (
user_id TEXT,
tag_name TEXT,
modelfile JSON,
timestamp BIGINT
)
"""
migrator.sql(query)
def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database):
Model = migrator.orm['model']
Modelfile = migrator.orm['modelfile']
models = Model.select()
for model in models:
# Extract and transform data in Python
meta = json.loads(model.meta)
modelfile_data = {
'title': model.name,
'desc': meta.get('description'),
'imageUrl': meta.get('profile_image_url'),
'content': meta.get('ollama', {}).get('modelfile'),
'suggestionPrompts': meta.get('suggestion_prompts'),
'categories': meta.get('categories'),
'user': {k: v for k, v in meta.get('user', {}).items() if k != 'community'},
}
# Insert the processed data back into the 'modelfile' table
Modelfile.create(
user_id=model.user_id,
tag_name=model.id,
modelfile=modelfile_data,
timestamp=model.created_at,
)
@@ -1,47 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields settings to the 'user' table
migrator.add_fields('user', settings=pw.TextField(null=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Remove the settings field
migrator.remove_fields('user', 'settings')
@@ -1,60 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Tool(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
name = pw.TextField()
content = pw.TextField()
specs = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'tool'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('tool')
@@ -1,47 +0,0 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields info to the 'user' table
migrator.add_fields('user', info=pw.TextField(null=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Remove the settings field
migrator.remove_fields('user', 'info')
@@ -1,54 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class File(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
filename = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'file'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('file')
@@ -1,60 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Function(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
name = pw.TextField()
type = pw.TextField()
content = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'function'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('function')
@@ -1,49 +0,0 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('tool', valves=pw.TextField(null=True))
migrator.add_fields('function', valves=pw.TextField(null=True))
migrator.add_fields('function', is_active=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('tool', 'valves')
migrator.remove_fields('function', 'valves')
migrator.remove_fields('function', 'is_active')
@@ -1,44 +0,0 @@
"""Peewee migrations -- 017_add_user_oauth_sub.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
'user',
oauth_sub=pw.TextField(null=True, unique=True),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('user', 'oauth_sub')
@@ -1,48 +0,0 @@
"""Peewee migrations -- 017_add_user_oauth_sub.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
'function',
is_global=pw.BooleanField(default=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('function', 'is_global')
-84
View File
@@ -1,84 +0,0 @@
import logging
import os
from contextvars import ContextVar
from peewee import *
from peewee import InterfaceError as PeeWeeInterfaceError
from peewee import PostgresqlDatabase
from playhouse.db_url import connect, parse
from playhouse.shortcuts import ReconnectMixin
log = logging.getLogger(__name__)
db_state_default = {'closed': None, 'conn': None, 'ctx': None, 'transactions': None}
db_state = ContextVar('db_state', default=db_state_default.copy())
class PeeweeConnectionState(object):
def __init__(self, **kwargs):
super().__setattr__('_state', db_state)
super().__init__(**kwargs)
def __setattr__(self, name, value):
self._state.get()[name] = value
def __getattr__(self, name):
value = self._state.get()[name]
return value
class CustomReconnectMixin(ReconnectMixin):
reconnect_errors = (
# psycopg2
(OperationalError, 'termin'),
(InterfaceError, 'closed'),
# peewee
(PeeWeeInterfaceError, 'closed'),
)
class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
pass
def register_connection(db_url):
# Check if using SQLCipher protocol
if db_url.startswith('sqlite+sqlcipher://'):
database_password = os.environ.get('DATABASE_PASSWORD')
if not database_password or database_password.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
from playhouse.sqlcipher_ext import SqlCipherDatabase
# Parse the database path from SQLCipher URL
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
db_path = db_url.replace('sqlite+sqlcipher://', '')
# Use Peewee's native SqlCipherDatabase with encryption
db = SqlCipherDatabase(db_path, passphrase=database_password)
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to encrypted SQLite database using SQLCipher')
else:
# Standard database connection (existing logic)
db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to PostgreSQL database')
# Get the connection details
connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection)
db.connect(reuse_if_open=True)
elif isinstance(db, SqliteDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to SQLite database')
else:
raise ValueError('Unsupported database connection')
return db
+1059 -1219
View File
File diff suppressed because it is too large Load Diff
+65 -100
View File
@@ -1,120 +1,85 @@
import logging
from logging.config import fileConfig
from __future__ import annotations
from alembic import context
# Alembic environment configuration runner.
# Coordinates database migrations in both offline and online execution modes.
import logging.config
import logging
import alembic.context
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.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
from open_webui.internal.db import extract_ssl_mode_from_url, reattach_ssl_mode_to_url
from sqlalchemy import engine_from_config, pool, create_engine
from sqlalchemy import create_engine, engine_from_config, pool
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# Re-apply JSON formatter after fileConfig replaces handlers.
alembic_config = alembic.context.config
if alembic_config.config_file_name:
logging.config.fileConfig(alembic_config.config_file_name, disable_existing_loggers=False)
if LOG_FORMAT == 'json':
from open_webui.env import JSONFormatter
for handler in logging.root.handlers:
handler.setFormatter(JSONFormatter())
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Auth.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
DB_URL = DATABASE_URL
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2, not asyncpg).
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DB_URL)
DB_URL = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode) if ssl_mode else DB_URL
if DB_URL:
config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%'))
for log_handler in logging.root.handlers:
log_handler.setFormatter(JSONFormatter())
migration_metadata = Auth.metadata
target_db_url = DATABASE_URL
base_url, ssl_query_params = extract_ssl_params_from_url(target_db_url)
if ssl_query_params:
target_db_url = reattach_ssl_params_to_url(base_url, ssl_query_params)
if target_db_url:
alembic_config.set_main_option('sqlalchemy.url', target_db_url.replace('%', '%%'))
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
"""Execute Alembic migrations in offline mode (outputs raw SQL DDL)."""
db_connection_url = alembic_config.get_main_option('sqlalchemy.url')
alembic.context.configure(
url=db_connection_url,
target_metadata=migration_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
with context.begin_transaction():
context.run_migrations()
def _get_engine_connectable():
"""Build the database engine based on target URL and authentication credentials."""
if target_db_url and target_db_url.startswith('sqlite+sqlcipher://'):
if not DATABASE_PASSWORD or not DATABASE_PASSWORD.strip():
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
raw_db_path = target_db_url.replace('sqlite+sqlcipher://', '')
if raw_db_path.startswith('/'):
raw_db_path = raw_db_path[1:]
def _sqlite_cipher_creator():
import sqlcipher3
cipher_conn = sqlcipher3.connect(raw_db_path, check_same_thread=False)
cipher_conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return cipher_conn
return create_engine('sqlite://', creator=_sqlite_cipher_creator, echo=False)
return engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Handle SQLCipher URLs
if DB_URL and DB_URL.startswith('sqlite+sqlcipher://'):
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
# Extract database path from SQLCipher URL
db_path = DB_URL.replace('sqlite+sqlcipher://', '')
if db_path.startswith('/'):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
import sqlcipher3
conn = sqlcipher3.connect(db_path, check_same_thread=False)
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return conn
connectable = create_engine(
'sqlite://', # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
"""Execute migrations against a live database connection."""
live_connectable = _get_engine_connectable()
enable_iam_token_auth(live_connectable)
with live_connectable.connect() as live_connection:
alembic.context.configure(
connection=live_connection,
target_metadata=migration_metadata,
)
else:
# Standard database connection (existing logic)
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
with alembic.context.begin_transaction():
alembic.context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
# Alembic execution entrypoint branch
if alembic.context.is_offline_mode():
run_migrations_offline() # run in offline mode
if not alembic.context.is_offline_mode():
run_migrations_online() # run in online mode
+14 -9
View File
@@ -1,15 +1,20 @@
from alembic import op
from sqlalchemy import Inspector
from __future__ import annotations
"""Alembic migration utilities."""
from alembic import op # noqa: E402 — alembic runtime context
from sqlalchemy import inspect # metadata inspection
def get_existing_tables():
con = op.get_bind()
inspector = Inspector.from_engine(con)
tables = set(inspector.get_table_names())
return tables
# --- database helper functions ---
def get_existing_tables() -> set[str]:
"""Return table names already present in the database."""
conn = op.get_bind()
return set(inspect(conn).get_table_names())
def get_revision_id():
def get_revision_id() -> str:
"""Generate a short random revision identifier."""
import uuid
return str(uuid.uuid4()).replace('-', '')[:12]
return uuid.uuid4().hex[:12]
@@ -6,8 +6,8 @@ Create Date: 2025-08-13 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '018012973d35'
down_revision = 'd31026856c01'
@@ -16,18 +16,34 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
def _idx_exists(table, idx_name):
return any(i['name'] == idx_name for i in inspector.get_indexes(table))
# Chat table indexes
op.create_index('folder_id_idx', 'chat', ['folder_id'])
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
if not _idx_exists('chat', 'folder_id_idx'):
op.create_index('folder_id_idx', 'chat', ['folder_id'])
if not _idx_exists('chat', 'user_id_pinned_idx'):
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
if not _idx_exists('chat', 'user_id_archived_idx'):
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
if not _idx_exists('chat', 'updated_at_user_id_idx'):
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
if not _idx_exists('chat', 'folder_id_user_id_idx'):
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
# Tag table index
op.create_index('user_id_idx', 'tag', ['user_id'])
if not _idx_exists('tag', 'user_id_idx'):
op.create_index('user_id_idx', 'tag', ['user_id'])
# Function table index
op.create_index('is_global_idx', 'function', ['is_global'])
# Function table index (only if is_global column exists — added by a later migration)
conn = op.get_bind()
inspector = sa.inspect(conn)
func_cols = {c['name'] for c in inspector.get_columns('function')}
if 'is_global' in func_cols and not _idx_exists('function', 'is_global_idx'):
op.create_index('is_global_idx', 'function', ['is_global'])
def downgrade():
@@ -6,13 +6,13 @@ Create Date: 2024-10-09 21:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update, column
from sqlalchemy.engine.reflection import Inspector
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql import column, select, table, update
revision = '1af9b942657b'
down_revision = '242a2047eae0'
branch_labels = None
@@ -93,8 +93,11 @@ def upgrade():
conn.execute(update_stmt)
# Add columns `pinned` and `meta` to 'chat'
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
chat_columns = {c['name'] for c in inspector.get_columns('chat')}
if 'pinned' not in chat_columns:
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
if 'meta' not in chat_columns:
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String()))
chat = table(
@@ -6,12 +6,12 @@ Create Date: 2024-10-09 21:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import select, table, update
revision = '242a2047eae0'
down_revision = '6a39f3d8e55c'
branch_labels = None
@@ -47,34 +47,41 @@ def upgrade():
# If the column is already JSON, no need to do anything
pass
# Step 3: Migrate data from 'old_chat' to 'chat'
chat_table = table(
'chat',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('old_chat', sa.Text()),
sa.Column('chat', sa.JSON()),
)
# Step 3: Migrate data from 'old_chat' to 'chat' (only if old_chat exists)
# Re-check columns after potential rename above
current_cols = {c['name'] for c in sa.inspect(conn).get_columns('chat')}
if 'old_chat' in current_cols:
chat_table = table(
'chat',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('old_chat', sa.Text()),
sa.Column('chat', sa.JSON()),
)
# - Selecting all data from the table
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
for row in results:
try:
# Convert text JSON to actual JSON object, assuming the text is in JSON format
json_data = json.loads(row.old_chat)
except json.JSONDecodeError:
json_data = None # Handle cases where the text cannot be converted to JSON
# - Selecting all data from the table
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
for row in results:
try:
# Convert text JSON to actual JSON object, assuming the text is in JSON format
json_data = json.loads(row.old_chat)
except json.JSONDecodeError:
json_data = None # Handle cases where the text cannot be converted to JSON
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
# Step 4: Drop 'old_chat' column
print("Dropping 'old_chat' column")
op.drop_column('chat', 'old_chat')
# Step 4: Drop 'old_chat' column
print("Dropping 'old_chat' column")
op.drop_column('chat', 'old_chat')
def downgrade():
conn = op.get_bind()
columns = {col['name'] for col in sa.inspect(conn).get_columns('chat')}
# Step 1: Add 'old_chat' column back as Text
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
if 'old_chat' not in columns:
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
# Step 2: Convert 'chat' JSON data back to text and store in 'old_chat'
chat_table = table(
@@ -84,14 +91,14 @@ def downgrade():
sa.Column('old_chat', sa.Text()),
)
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.chat))
for row in results:
text_data = json.dumps(row.chat) if row.chat is not None else None
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
if 'chat' in columns:
results = conn.execute(select(chat_table.c.id, chat_table.c.chat))
for row in results:
text_data = json.dumps(row.chat) if row.chat is not None else None
conn.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
# Step 3: Remove the new 'chat' JSON column
op.drop_column('chat', 'chat')
# Step 3: Remove the new 'chat' JSON column
op.drop_column('chat', 'chat')
# Step 4: Rename 'old_chat' back to 'chat'
op.alter_column('chat', 'old_chat', new_column_name='chat', existing_type=sa.Text())
@@ -8,9 +8,9 @@ Create Date: 2025-11-27 03:07:56.200231
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '2f1211949ecc'
@@ -20,63 +20,76 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# New columns to be added to channel_member table
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
op.add_column(
'channel_member',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
default=True,
server_default=sa.sql.expression.true(),
),
)
op.add_column(
'channel_member',
sa.Column(
'is_channel_muted',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column(
'channel_member',
sa.Column(
'is_channel_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
if 'status' not in cm_cols:
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
if 'is_active' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
default=True,
server_default=sa.sql.expression.true(),
),
)
if 'is_channel_muted' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_channel_muted',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'is_channel_pinned' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_channel_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'data' not in cm_cols:
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
if 'meta' not in cm_cols:
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
if 'joined_at' not in cm_cols:
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
if 'left_at' not in cm_cols:
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
if 'last_read_at' not in cm_cols:
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
if 'updated_at' not in cm_cols:
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
# New columns to be added to message table
op.add_column(
'message',
sa.Column(
'is_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
msg_cols = {c['name'] for c in inspector.get_columns('message')}
if 'is_pinned' not in msg_cols:
op.add_column(
'message',
sa.Column(
'is_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'pinned_at' not in msg_cols:
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
if 'pinned_by' not in msg_cols:
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
def downgrade() -> None:
@@ -6,11 +6,11 @@ Create Date: 2026-01-23 17:15:00.000000
"""
from typing import Sequence, Union
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
revision: str = '374d2f66af06'
down_revision: Union[str, None] = 'c440947495f3'
@@ -20,150 +20,167 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Step 1: Read existing data from OLD table (schema likely command as PK)
# We use batch_alter previously, but we want to move to new table.
# We need to assume the OLD structure.
# If the final state already exists (prompt has 'id' PK + prompt_history exists),
# the migration completed successfully on a prior run — nothing to do.
if 'prompt_history' in existing_tables and 'prompt_new' not in existing_tables:
# prompt_history exists and prompt_new was already renamed → done
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'id' in prompt_cols and 'version_id' in prompt_cols:
return
old_prompt_table = sa.table(
'prompt',
sa.column('command', sa.Text()),
sa.column('user_id', sa.Text()),
sa.column('title', sa.Text()),
sa.column('content', sa.Text()),
sa.column('timestamp', sa.BigInteger()),
sa.column('access_control', sa.JSON()),
)
# Check if table exists/read data
try:
existing_prompts = conn.execute(
sa.select(
old_prompt_table.c.command,
old_prompt_table.c.user_id,
old_prompt_table.c.title,
old_prompt_table.c.content,
old_prompt_table.c.timestamp,
old_prompt_table.c.access_control,
# Step 1: Read existing data from OLD table (schema: command as PK)
# Only read if the old-schema prompt table still exists (has 'command' but no 'version_id')
existing_prompts = []
if 'prompt' in existing_tables and 'prompt_new' not in existing_tables:
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'command' in prompt_cols and 'version_id' not in prompt_cols:
old_prompt_table = sa.table(
'prompt',
sa.column('command', sa.Text()),
sa.column('user_id', sa.Text()),
sa.column('title', sa.Text()),
sa.column('content', sa.Text()),
sa.column('timestamp', sa.BigInteger()),
sa.column('access_control', sa.JSON()),
)
).fetchall()
except Exception:
# Fallback if table doesn't exist (new install)
existing_prompts = []
try:
existing_prompts = conn.execute(
sa.select(
old_prompt_table.c.command,
old_prompt_table.c.user_id,
old_prompt_table.c.title,
old_prompt_table.c.content,
old_prompt_table.c.timestamp,
old_prompt_table.c.access_control,
)
).fetchall()
except Exception:
existing_prompts = []
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
op.create_table(
'prompt_new',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('command', sa.String(), unique=True, index=True),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('version_id', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
# Step 3: Create prompt_history table
op.create_table(
'prompt_history',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('snapshot', sa.JSON(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('commit_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
# Step 4: Migrate data
prompt_new_table = sa.table(
'prompt_new',
sa.column('id', sa.Text()),
sa.column('command', sa.String()),
sa.column('user_id', sa.String()),
sa.column('name', sa.Text()),
sa.column('content', sa.Text()),
sa.column('data', sa.JSON()),
sa.column('meta', sa.JSON()),
sa.column('access_control', sa.JSON()),
sa.column('is_active', sa.Boolean()),
sa.column('version_id', sa.Text()),
sa.column('tags', sa.JSON()),
sa.column('created_at', sa.BigInteger()),
sa.column('updated_at', sa.BigInteger()),
)
prompt_history_table = sa.table(
'prompt_history',
sa.column('id', sa.Text()),
sa.column('prompt_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('snapshot', sa.JSON()),
sa.column('user_id', sa.Text()),
sa.column('commit_message', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
for row in existing_prompts:
command = row[0]
user_id = row[1]
title = row[2]
content = row[3]
timestamp = row[4]
access_control = row[5]
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith('/') else command
# Insert into prompt_new
conn.execute(
sa.insert(prompt_new_table).values(
id=new_uuid,
command=clean_command,
user_id=user_id,
name=title,
content=content,
data={},
meta={},
access_control=access_control,
is_active=True,
version_id=history_uuid,
tags=[],
created_at=timestamp,
updated_at=timestamp,
)
# Step 2: Create new prompt table with 'id' as PRIMARY KEY (if not already created)
if 'prompt_new' not in existing_tables:
op.create_table(
'prompt_new',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('command', sa.String(), unique=True, index=True),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('version_id', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
# Create initial history entry
conn.execute(
sa.insert(prompt_history_table).values(
id=history_uuid,
prompt_id=new_uuid,
parent_id=None,
snapshot={
'name': title,
'content': content,
'command': clean_command,
'data': {},
'meta': {},
'access_control': access_control,
},
user_id=user_id,
commit_message=None,
created_at=timestamp,
)
# Step 3: Create prompt_history table (if not already created)
if 'prompt_history' not in existing_tables:
op.create_table(
'prompt_history',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('snapshot', sa.JSON(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('commit_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
# Step 5: Replace old table with new one
op.drop_table('prompt')
op.rename_table('prompt_new', 'prompt')
# Step 4: Migrate data (only if we have old data to migrate)
if existing_prompts:
prompt_new_table = sa.table(
'prompt_new',
sa.column('id', sa.Text()),
sa.column('command', sa.String()),
sa.column('user_id', sa.String()),
sa.column('name', sa.Text()),
sa.column('content', sa.Text()),
sa.column('data', sa.JSON()),
sa.column('meta', sa.JSON()),
sa.column('access_control', sa.JSON()),
sa.column('is_active', sa.Boolean()),
sa.column('version_id', sa.Text()),
sa.column('tags', sa.JSON()),
sa.column('created_at', sa.BigInteger()),
sa.column('updated_at', sa.BigInteger()),
)
prompt_history_table = sa.table(
'prompt_history',
sa.column('id', sa.Text()),
sa.column('prompt_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('snapshot', sa.JSON()),
sa.column('user_id', sa.Text()),
sa.column('commit_message', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
for row in existing_prompts:
command = row[0]
user_id = row[1]
title = row[2]
content = row[3]
timestamp = row[4]
access_control = row[5]
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith('/') else command
# Insert into prompt_new
conn.execute(
sa.insert(prompt_new_table).values(
id=new_uuid,
command=clean_command,
user_id=user_id,
name=title,
content=content,
data={},
meta={},
access_control=access_control,
is_active=True,
version_id=history_uuid,
tags=[],
created_at=timestamp,
updated_at=timestamp,
)
)
# Create initial history entry
conn.execute(
sa.insert(prompt_history_table).values(
id=history_uuid,
prompt_id=new_uuid,
parent_id=None,
snapshot={
'name': title,
'content': content,
'command': clean_command,
'data': {},
'meta': {},
'access_control': access_control,
},
user_id=user_id,
commit_message=None,
created_at=timestamp,
)
)
# Step 5: Replace old table with new one (only if prompt_new exists)
# Re-check tables after potential creation above
inspector.clear_cache()
current_tables = set(inspector.get_table_names())
if 'prompt_new' in current_tables:
if 'prompt' in current_tables:
op.drop_table('prompt')
op.rename_table('prompt_new', 'prompt')
def downgrade() -> None:
@@ -6,8 +6,8 @@ Create Date: 2024-12-30 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '3781e22d8b01'
down_revision = '7826ab40b532'
@@ -16,38 +16,50 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Add 'type' column to the 'channel' table
op.add_column(
'channel',
sa.Column(
'type',
sa.Text(),
nullable=True,
),
)
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
if 'type' not in channel_cols:
op.add_column(
'channel',
sa.Column(
'type',
sa.Text(),
nullable=True,
),
)
# Add 'parent_id' column to the 'message' table for threads
op.add_column(
'message',
sa.Column('parent_id', sa.Text(), nullable=True),
)
message_cols = {c['name'] for c in inspector.get_columns('message')}
if 'parent_id' not in message_cols:
op.add_column(
'message',
sa.Column('parent_id', sa.Text(), nullable=True),
)
op.create_table(
'message_reaction',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
)
if 'message_reaction' not in existing_tables:
op.create_table(
'message_reaction',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
)
op.create_table(
'channel_member',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
)
if 'channel_member' not in existing_tables:
op.create_table(
'channel_member',
sa.Column(
'id', sa.Text(), nullable=False, primary_key=True, unique=True
), # Record ID for the membership row
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
)
def downgrade():
@@ -6,13 +6,13 @@ Create Date: 2025-11-17 03:45:25.123939
"""
import uuid
import time
import json
import time
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '37f288994c47'
@@ -22,6 +22,13 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'group_member' in existing_tables:
return # Already created — skip everything
# 1. Create new table
op.create_table(
'group_member',
@@ -8,8 +8,8 @@ Create Date: 2025-09-08 14:19:59.583921
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '38d63c18f30f'
@@ -19,50 +19,39 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
inspector = sa.inspect(op.get_bind())
columns = inspector.get_columns('user')
existing_tables = set(inspector.get_table_names())
pk_columns = inspector.get_pk_constraint('user')['constrained_columns']
id_column = next((col for col in columns if col['name'] == 'id'), None)
# ── Create oauth_session table (idempotent) ───────────────────────
if 'oauth_session' not in existing_tables:
op.create_table(
'oauth_session',
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column(
'user_id',
sa.Text(),
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('provider', sa.Text(), nullable=False),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('expires_at', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
if id_column and not id_column.get('unique', False):
unique_constraints = inspector.get_unique_constraints('user')
unique_columns = {tuple(u['column_names']) for u in unique_constraints}
with op.batch_alter_table('user') as batch_op:
# If primary key is wrong, drop it
if pk_columns and pk_columns != ['id']:
batch_op.drop_constraint(inspector.get_pk_constraint('user')['name'], type_='primary')
# Add unique constraint if missing
if ('id',) not in unique_columns:
batch_op.create_unique_constraint('uq_user_id', ['id'])
# Re-create correct primary key
batch_op.create_primary_key('pk_user_id', ['id'])
# Create oauth_session table
op.create_table(
'oauth_session',
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column(
'user_id',
sa.Text(),
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('provider', sa.Text(), nullable=False),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('expires_at', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# Create indexes (idempotent — no-ops when table was just created
# with the columns above, and safe to call if indexes already exist).
existing_indexes = (
{idx['name'] for idx in inspector.get_indexes('oauth_session')} if 'oauth_session' in existing_tables else set()
)
# Create indexes for better performance
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
if 'idx_oauth_session_user_id' not in existing_indexes:
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
if 'idx_oauth_session_expires_at' not in existing_indexes:
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
if 'idx_oauth_session_user_provider' not in existing_indexes:
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
def downgrade() -> None:
@@ -6,13 +6,13 @@ Create Date: 2024-10-09 21:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update, column
from sqlalchemy.engine.reflection import Inspector
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql import column, select, table, update
revision = '3ab32c4b8f59'
down_revision = '1af9b942657b'
branch_labels = None
@@ -8,8 +8,8 @@ Create Date: 2025-08-21 02:07:18.078283
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '3af16a1c9fb6'
@@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
user_cols = {c['name'] for c in inspector.get_columns('user')}
if 'username' not in user_cols:
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
if 'bio' not in user_cols:
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
if 'gender' not in user_cols:
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
if 'date_of_birth' not in user_cols:
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
def downgrade() -> None:
@@ -0,0 +1,72 @@
"""add knowledge_directory table
Revision ID: 3c9b0ca343fd
Revises: a0b1c2d3e4f5
Create Date: 2026-05-13 21:58:40.832482
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3c9b0ca343fd'
down_revision: Union[str, None] = 'a0b1c2d3e4f5'
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)
existing_tables = set(inspector.get_table_names())
if 'knowledge_directory' not in existing_tables:
# Create knowledge_directory table
op.create_table(
'knowledge_directory',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('knowledge_id', sa.Text(), nullable=False),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['parent_id'], ['knowledge_directory.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'
),
)
op.create_index('ix_knowledge_directory_knowledge_id', 'knowledge_directory', ['knowledge_id'])
op.create_index('ix_knowledge_directory_parent_id', 'knowledge_directory', ['parent_id'])
# Add directory_id column to knowledge_file
kf_cols = {c['name'] for c in inspector.get_columns('knowledge_file')}
if 'directory_id' not in kf_cols:
with op.batch_alter_table('knowledge_file') as batch:
batch.add_column(sa.Column('directory_id', sa.Text(), nullable=True))
batch.create_foreign_key(
'fk_knowledge_file_directory_id',
'knowledge_directory',
['directory_id'],
['id'],
ondelete='SET NULL',
)
batch.create_index('ix_knowledge_file_directory_id', ['directory_id'])
def downgrade() -> None:
# Remove directory_id from knowledge_file
with op.batch_alter_table('knowledge_file') as batch:
batch.drop_index('ix_knowledge_file_directory_id')
batch.drop_constraint('fk_knowledge_file_directory_id', type_='foreignkey')
batch.drop_column('directory_id')
# Drop knowledge_directory table
op.drop_index('ix_knowledge_directory_parent_id', table_name='knowledge_directory')
op.drop_index('ix_knowledge_directory_knowledge_id', table_name='knowledge_directory')
op.drop_table('knowledge_directory')
@@ -6,16 +6,15 @@ Create Date: 2025-12-02 06:54:19.401334
"""
import json
import time
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
import open_webui.internal.db
import time
import json
import uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '3e0e00844bb0'
@@ -25,6 +24,13 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'knowledge_file' in existing_tables:
return # Already created — skip everything
op.create_table(
'knowledge_file',
sa.Column('id', sa.Text(), primary_key=True),
@@ -0,0 +1,584 @@
"""reshape config to per key rows
Revision ID: 3ff2c63645b8
Revises: 461111b60977
Create Date: 2026-06-17 00:50:51.477073
"""
import json
import time
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '3ff2c63645b8'
down_revision: Union[str, None] = '461111b60977'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Maps every dot-notation blob path to its legacy env/config key name.
# Built from the legacy persistent config declarations in config.py.
BLOB_PATH_TO_KEY = {
'audio.stt.allowed_extensions': 'AUDIO_STT_ALLOWED_EXTENSIONS',
'audio.stt.azure.api_key': 'AUDIO_STT_AZURE_API_KEY',
'audio.stt.azure.base_url': 'AUDIO_STT_AZURE_BASE_URL',
'audio.stt.azure.locales': 'AUDIO_STT_AZURE_LOCALES',
'audio.stt.azure.max_speakers': 'AUDIO_STT_AZURE_MAX_SPEAKERS',
'audio.stt.azure.region': 'AUDIO_STT_AZURE_REGION',
'audio.stt.deepgram.api_key': 'DEEPGRAM_API_KEY',
'audio.stt.engine': 'AUDIO_STT_ENGINE',
'audio.stt.mistral.api_base_url': 'AUDIO_STT_MISTRAL_API_BASE_URL',
'audio.stt.mistral.api_key': 'AUDIO_STT_MISTRAL_API_KEY',
'audio.stt.mistral.use_chat_completions': 'AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS',
'audio.stt.model': 'AUDIO_STT_MODEL',
'audio.stt.openai.api_base_url': 'AUDIO_STT_OPENAI_API_BASE_URL',
'audio.stt.openai.api_key': 'AUDIO_STT_OPENAI_API_KEY',
'audio.stt.supported_content_types': 'AUDIO_STT_SUPPORTED_CONTENT_TYPES',
'audio.stt.whisper_model': 'WHISPER_MODEL',
'audio.tts.api_key': 'AUDIO_TTS_API_KEY',
'audio.tts.azure.speech_base_url': 'AUDIO_TTS_AZURE_SPEECH_BASE_URL',
'audio.tts.azure.speech_output_format': 'AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT',
'audio.tts.azure.speech_region': 'AUDIO_TTS_AZURE_SPEECH_REGION',
'audio.tts.engine': 'AUDIO_TTS_ENGINE',
'audio.tts.mistral.api_base_url': 'AUDIO_TTS_MISTRAL_API_BASE_URL',
'audio.tts.mistral.api_key': 'AUDIO_TTS_MISTRAL_API_KEY',
'audio.tts.model': 'AUDIO_TTS_MODEL',
'audio.tts.openai.api_base_url': 'AUDIO_TTS_OPENAI_API_BASE_URL',
'audio.tts.openai.api_key': 'AUDIO_TTS_OPENAI_API_KEY',
'audio.tts.openai.params': 'AUDIO_TTS_OPENAI_PARAMS',
'audio.tts.split_on': 'AUDIO_TTS_SPLIT_ON',
'audio.tts.voice': 'AUDIO_TTS_VOICE',
'auth.admin.email': 'ADMIN_EMAIL',
'auth.admin.show': 'SHOW_ADMIN_DETAILS',
'auth.api_key.allowed_endpoints': 'API_KEYS_ALLOWED_ENDPOINTS',
'auth.api_key.endpoint_restrictions': 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS',
'auth.enable_api_keys': 'ENABLE_API_KEYS',
'auth.jwt_expiry': 'JWT_EXPIRES_IN',
'automations.enable': 'ENABLE_AUTOMATIONS',
'automations.max_count': 'AUTOMATION_MAX_COUNT',
'automations.min_interval': 'AUTOMATION_MIN_INTERVAL',
'calendar.enable': 'ENABLE_CALENDAR',
'channels.enable': 'ENABLE_CHANNELS',
'code_execution.enable': 'ENABLE_CODE_EXECUTION',
'code_execution.engine': 'CODE_EXECUTION_ENGINE',
'code_execution.jupyter.auth': 'CODE_EXECUTION_JUPYTER_AUTH',
'code_execution.jupyter.auth_password': 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD',
'code_execution.jupyter.auth_token': 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN',
'code_execution.jupyter.timeout': 'CODE_EXECUTION_JUPYTER_TIMEOUT',
'code_execution.jupyter.url': 'CODE_EXECUTION_JUPYTER_URL',
'code_interpreter.enable': 'ENABLE_CODE_INTERPRETER',
'code_interpreter.engine': 'CODE_INTERPRETER_ENGINE',
'code_interpreter.jupyter.auth': 'CODE_INTERPRETER_JUPYTER_AUTH',
'code_interpreter.jupyter.auth_password': 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD',
'code_interpreter.jupyter.auth_token': 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN',
'code_interpreter.jupyter.timeout': 'CODE_INTERPRETER_JUPYTER_TIMEOUT',
'code_interpreter.jupyter.url': 'CODE_INTERPRETER_JUPYTER_URL',
'code_interpreter.prompt_template': 'CODE_INTERPRETER_PROMPT_TEMPLATE',
'direct.enable': 'ENABLE_DIRECT_CONNECTIONS',
'evaluation.arena.enable': 'ENABLE_EVALUATION_ARENA_MODELS',
'evaluation.arena.models': 'EVALUATION_ARENA_MODELS',
'file.image_compression_height': 'FILE_IMAGE_COMPRESSION_HEIGHT',
'file.image_compression_width': 'FILE_IMAGE_COMPRESSION_WIDTH',
'folders.enable': 'ENABLE_FOLDERS',
'folders.max_file_count': 'FOLDER_MAX_FILE_COUNT',
'google_drive.api_key': 'GOOGLE_DRIVE_API_KEY',
'google_drive.client_id': 'GOOGLE_DRIVE_CLIENT_ID',
'google_drive.enable': 'ENABLE_GOOGLE_DRIVE_INTEGRATION',
'image_generation.automatic1111.api_auth': 'AUTOMATIC1111_API_AUTH',
'image_generation.automatic1111.api_params': 'AUTOMATIC1111_PARAMS',
'image_generation.automatic1111.base_url': 'AUTOMATIC1111_BASE_URL',
'image_generation.comfyui.api_key': 'COMFYUI_API_KEY',
'image_generation.comfyui.base_url': 'COMFYUI_BASE_URL',
'image_generation.comfyui.nodes': 'COMFYUI_WORKFLOW_NODES',
'image_generation.comfyui.workflow': 'COMFYUI_WORKFLOW',
'image_generation.enable': 'ENABLE_IMAGE_GENERATION',
'image_generation.engine': 'IMAGE_GENERATION_ENGINE',
'image_generation.gemini.api_base_url': 'IMAGES_GEMINI_API_BASE_URL',
'image_generation.gemini.api_key': 'IMAGES_GEMINI_API_KEY',
'image_generation.gemini.endpoint_method': 'IMAGES_GEMINI_ENDPOINT_METHOD',
'image_generation.model': 'IMAGE_GENERATION_MODEL',
'image_generation.openai.api_base_url': 'IMAGES_OPENAI_API_BASE_URL',
'image_generation.openai.api_key': 'IMAGES_OPENAI_API_KEY',
'image_generation.openai.api_version': 'IMAGES_OPENAI_API_VERSION',
'image_generation.openai.params': 'IMAGES_OPENAI_API_PARAMS',
'image_generation.prompt.enable': 'ENABLE_IMAGE_PROMPT_GENERATION',
'image_generation.size': 'IMAGE_SIZE',
'image_generation.steps': 'IMAGE_STEPS',
'images.edit.comfyui.api_key': 'IMAGES_EDIT_COMFYUI_API_KEY',
'images.edit.comfyui.base_url': 'IMAGES_EDIT_COMFYUI_BASE_URL',
'images.edit.comfyui.nodes': 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES',
'images.edit.comfyui.workflow': 'IMAGES_EDIT_COMFYUI_WORKFLOW',
'images.edit.enable': 'ENABLE_IMAGE_EDIT',
'images.edit.engine': 'IMAGE_EDIT_ENGINE',
'images.edit.gemini.api_base_url': 'IMAGES_EDIT_GEMINI_API_BASE_URL',
'images.edit.gemini.api_key': 'IMAGES_EDIT_GEMINI_API_KEY',
'images.edit.model': 'IMAGE_EDIT_MODEL',
'images.edit.openai.api_base_url': 'IMAGES_EDIT_OPENAI_API_BASE_URL',
'images.edit.openai.api_key': 'IMAGES_EDIT_OPENAI_API_KEY',
'images.edit.openai.api_version': 'IMAGES_EDIT_OPENAI_API_VERSION',
'images.edit.size': 'IMAGE_EDIT_SIZE',
'ldap.enable': 'ENABLE_LDAP',
'ldap.group.enable_creation': 'ENABLE_LDAP_GROUP_CREATION',
'ldap.group.enable_management': 'ENABLE_LDAP_GROUP_MANAGEMENT',
'ldap.server.app_dn': 'LDAP_APP_DN',
'ldap.server.app_password': 'LDAP_APP_PASSWORD',
'ldap.server.attribute_for_groups': 'LDAP_ATTRIBUTE_FOR_GROUPS',
'ldap.server.attribute_for_mail': 'LDAP_ATTRIBUTE_FOR_MAIL',
'ldap.server.attribute_for_username': 'LDAP_ATTRIBUTE_FOR_USERNAME',
'ldap.server.ca_cert_file': 'LDAP_CA_CERT_FILE',
'ldap.server.ciphers': 'LDAP_CIPHERS',
'ldap.server.host': 'LDAP_SERVER_HOST',
'ldap.server.label': 'LDAP_SERVER_LABEL',
'ldap.server.port': 'LDAP_SERVER_PORT',
'ldap.server.search_filter': 'LDAP_SEARCH_FILTER',
'ldap.server.use_tls': 'LDAP_USE_TLS',
'ldap.server.users_dn': 'LDAP_SEARCH_BASE',
'ldap.server.validate_cert': 'LDAP_VALIDATE_CERT',
'memories.enable': 'ENABLE_MEMORIES',
'models.base_models_cache': 'ENABLE_BASE_MODELS_CACHE',
'models.default_metadata': 'DEFAULT_MODEL_METADATA',
'models.default_params': 'DEFAULT_MODEL_PARAMS',
'notes.enable': 'ENABLE_NOTES',
# OAuth — direct paths
'oauth.admin_roles': 'OAUTH_ADMIN_ROLES',
'oauth.allowed_domains': 'OAUTH_ALLOWED_DOMAINS',
'oauth.allowed_roles': 'OAUTH_ALLOWED_ROLES',
'oauth.audience': 'OAUTH_AUDIENCE',
'oauth.auto_redirect': 'OAUTH_AUTO_REDIRECT',
'oauth.blocked_groups': 'OAUTH_BLOCKED_GROUPS',
'oauth.client.timeout': 'OAUTH_CLIENT_TIMEOUT',
'oauth.enable_group_creation': 'ENABLE_OAUTH_GROUP_CREATION',
'oauth.enable_group_mapping': 'ENABLE_OAUTH_GROUP_MANAGEMENT',
'oauth.enable_role_mapping': 'ENABLE_OAUTH_ROLE_MANAGEMENT',
'oauth.enable_signup': 'ENABLE_OAUTH_SIGNUP',
'oauth.group_default_share': 'OAUTH_GROUP_DEFAULT_SHARE',
'oauth.merge_accounts_by_email': 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL',
'oauth.refresh_token_include_scope': 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE',
'oauth.roles_claim': 'OAUTH_ROLES_CLAIM',
'oauth.update_email_on_login': 'OAUTH_UPDATE_EMAIL_ON_LOGIN',
'oauth.update_name_on_login': 'OAUTH_UPDATE_NAME_ON_LOGIN',
'oauth.update_picture_on_login': 'OAUTH_UPDATE_PICTURE_ON_LOGIN',
# OAuth — generic provider paths
'oauth.client_id': 'OAUTH_CLIENT_ID',
'oauth.client_secret': 'OAUTH_CLIENT_SECRET',
'oauth.code_challenge_method': 'OAUTH_CODE_CHALLENGE_METHOD',
'oauth.email_claim': 'OAUTH_EMAIL_CLAIM',
'oauth.end_session_endpoint': 'OPENID_END_SESSION_ENDPOINT',
'oauth.group_claim': 'OAUTH_GROUP_CLAIM',
'oauth.picture_claim': 'OAUTH_PICTURE_CLAIM',
'oauth.provider_name': 'OAUTH_PROVIDER_NAME',
'oauth.provider_url': 'OPENID_PROVIDER_URL',
'oauth.redirect_uri': 'OPENID_REDIRECT_URI',
'oauth.scopes': 'OAUTH_SCOPES',
'oauth.sub_claim': 'OAUTH_SUB_CLAIM',
'oauth.timeout': 'OAUTH_TIMEOUT',
'oauth.token_endpoint_auth_method': 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD',
'oauth.username_claim': 'OAUTH_USERNAME_CLAIM',
# OAuth — OIDC nested paths (flattened)
'oauth.oidc.avatar_claim': 'OAUTH_PICTURE_CLAIM',
'oauth.oidc.client_id': 'OAUTH_CLIENT_ID',
'oauth.oidc.client_secret': 'OAUTH_CLIENT_SECRET',
'oauth.oidc.code_challenge_method': 'OAUTH_CODE_CHALLENGE_METHOD',
'oauth.oidc.email_claim': 'OAUTH_EMAIL_CLAIM',
'oauth.oidc.end_session_endpoint': 'OPENID_END_SESSION_ENDPOINT',
'oauth.oidc.group_claim': 'OAUTH_GROUP_CLAIM', # renamed from OAUTH_GROUPS_CLAIM
'oauth.oidc.oauth_timeout': 'OAUTH_TIMEOUT',
'oauth.oidc.provider_name': 'OAUTH_PROVIDER_NAME',
'oauth.oidc.provider_url': 'OPENID_PROVIDER_URL',
'oauth.oidc.redirect_uri': 'OPENID_REDIRECT_URI',
'oauth.oidc.scopes': 'OAUTH_SCOPES',
'oauth.oidc.sub_claim': 'OAUTH_SUB_CLAIM',
'oauth.oidc.token_endpoint_auth_method': 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD',
'oauth.oidc.username_claim': 'OAUTH_USERNAME_CLAIM',
# OAuth — provider-specific
'oauth.feishu.client_id': 'FEISHU_CLIENT_ID',
'oauth.feishu.client_secret': 'FEISHU_CLIENT_SECRET',
'oauth.feishu.redirect_uri': 'FEISHU_REDIRECT_URI',
'oauth.feishu.scope': 'FEISHU_OAUTH_SCOPE',
'oauth.github.client_id': 'GITHUB_CLIENT_ID',
'oauth.github.client_secret': 'GITHUB_CLIENT_SECRET',
'oauth.github.redirect_uri': 'GITHUB_CLIENT_REDIRECT_URI',
'oauth.github.scope': 'GITHUB_CLIENT_SCOPE',
'oauth.google.client_id': 'GOOGLE_CLIENT_ID',
'oauth.google.client_secret': 'GOOGLE_CLIENT_SECRET',
'oauth.google.redirect_uri': 'GOOGLE_REDIRECT_URI',
'oauth.google.scope': 'GOOGLE_OAUTH_SCOPE',
'oauth.microsoft.client_id': 'MICROSOFT_CLIENT_ID',
'oauth.microsoft.client_secret': 'MICROSOFT_CLIENT_SECRET',
'oauth.microsoft.login_base_url': 'MICROSOFT_CLIENT_LOGIN_BASE_URL',
'oauth.microsoft.picture_url': 'MICROSOFT_CLIENT_PICTURE_URL',
'oauth.microsoft.redirect_uri': 'MICROSOFT_REDIRECT_URI',
'oauth.microsoft.scope': 'MICROSOFT_OAUTH_SCOPE',
'oauth.microsoft.tenant_id': 'MICROSOFT_CLIENT_TENANT_ID',
# Ollama / OpenAI
'ollama.api_configs': 'OLLAMA_API_CONFIGS',
'ollama.base_urls': 'OLLAMA_BASE_URLS',
'ollama.enable': 'ENABLE_OLLAMA_API',
'onedrive.enable': 'ENABLE_ONEDRIVE_INTEGRATION',
'onedrive.sharepoint_tenant_id': 'ONEDRIVE_SHAREPOINT_TENANT_ID',
'onedrive.sharepoint_url': 'ONEDRIVE_SHAREPOINT_URL',
'openai.api_base_urls': 'OPENAI_API_BASE_URLS',
'openai.api_configs': 'OPENAI_API_CONFIGS',
'openai.api_keys': 'OPENAI_API_KEYS',
'openai.enable': 'ENABLE_OPENAI_API',
# RAG
'rag.content_extraction_engine': 'CONTENT_EXTRACTION_ENGINE',
'rag.datalab_marker_use_llm': 'DATALAB_MARKER_USE_LLM',
'rag.mistral_ocr_api_base_url': 'MISTRAL_OCR_API_BASE_URL',
'rag.azure_openai.api_key': 'RAG_AZURE_OPENAI_API_KEY',
'rag.azure_openai.api_version': 'RAG_AZURE_OPENAI_API_VERSION',
'rag.azure_openai.base_url': 'RAG_AZURE_OPENAI_BASE_URL',
'rag.bypass_embedding_and_retrieval': 'BYPASS_EMBEDDING_AND_RETRIEVAL',
'rag.chunk_min_size_target': 'CHUNK_MIN_SIZE_TARGET',
'rag.chunk_overlap': 'CHUNK_OVERLAP',
'rag.chunk_size': 'CHUNK_SIZE',
'rag.datalab_marker_additional_config': 'DATALAB_MARKER_ADDITIONAL_CONFIG',
'rag.datalab_marker_api_base_url': 'DATALAB_MARKER_API_BASE_URL',
'rag.datalab_marker_api_key': 'DATALAB_MARKER_API_KEY',
'rag.datalab_marker_disable_image_extraction': 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION',
'rag.datalab_marker_force_ocr': 'DATALAB_MARKER_FORCE_OCR',
'rag.datalab_marker_format_lines': 'DATALAB_MARKER_FORMAT_LINES',
'rag.datalab_marker_output_format': 'DATALAB_MARKER_OUTPUT_FORMAT',
'rag.datalab_marker_paginate': 'DATALAB_MARKER_PAGINATE',
'rag.datalab_marker_skip_cache': 'DATALAB_MARKER_SKIP_CACHE',
'rag.datalab_marker_strip_existing_ocr': 'DATALAB_MARKER_STRIP_EXISTING_OCR',
'rag.docling_api_key': 'DOCLING_API_KEY',
'rag.docling_params': 'DOCLING_PARAMS',
'rag.docling_server_url': 'DOCLING_SERVER_URL',
'rag.document_intelligence_endpoint': 'DOCUMENT_INTELLIGENCE_ENDPOINT',
'rag.document_intelligence_key': 'DOCUMENT_INTELLIGENCE_KEY',
'rag.document_intelligence_model': 'DOCUMENT_INTELLIGENCE_MODEL',
'rag.embedding_batch_size': 'RAG_EMBEDDING_BATCH_SIZE',
'rag.embedding_concurrent_requests': 'RAG_EMBEDDING_CONCURRENT_REQUESTS',
'rag.embedding_engine': 'RAG_EMBEDDING_ENGINE',
'rag.embedding_model': 'RAG_EMBEDDING_MODEL',
'rag.enable_async_embedding': 'ENABLE_ASYNC_EMBEDDING',
'rag.enable_hybrid_search': 'ENABLE_RAG_HYBRID_SEARCH',
'rag.enable_hybrid_search_enriched_texts': 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS',
'rag.enable_markdown_header_text_splitter': 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER',
'rag.external_document_loader_api_key': 'EXTERNAL_DOCUMENT_LOADER_API_KEY',
'rag.external_document_loader_url': 'EXTERNAL_DOCUMENT_LOADER_URL',
'rag.external_reranker_api_key': 'RAG_EXTERNAL_RERANKER_API_KEY',
'rag.external_reranker_timeout': 'RAG_EXTERNAL_RERANKER_TIMEOUT',
'rag.external_reranker_url': 'RAG_EXTERNAL_RERANKER_URL',
'rag.file.allowed_extensions': 'RAG_ALLOWED_FILE_EXTENSIONS',
'rag.file.max_count': 'RAG_FILE_MAX_COUNT',
'rag.file.max_size': 'RAG_FILE_MAX_SIZE',
'rag.full_context': 'RAG_FULL_CONTEXT',
'rag.hybrid_bm25_weight': 'RAG_HYBRID_BM25_WEIGHT',
'rag.mineru_api_key': 'MINERU_API_KEY',
'rag.mineru_api_mode': 'MINERU_API_MODE',
'rag.mineru_api_timeout': 'MINERU_API_TIMEOUT',
'rag.mineru_api_url': 'MINERU_API_URL',
'rag.mineru_file_extensions': 'MINERU_FILE_EXTENSIONS',
'rag.mineru_params': 'MINERU_PARAMS',
'rag.mistral_ocr_api_key': 'MISTRAL_OCR_API_KEY',
'rag.ollama.key': 'RAG_OLLAMA_API_KEY',
'rag.ollama.url': 'RAG_OLLAMA_BASE_URL',
'rag.openai_api_base_url': 'RAG_OPENAI_API_BASE_URL',
'rag.openai_api_key': 'RAG_OPENAI_API_KEY',
'rag.paddleocr_vl_base_url': 'PADDLEOCR_VL_BASE_URL',
'rag.paddleocr_vl_token': 'PADDLEOCR_VL_TOKEN',
'rag.pdf_extract_images': 'PDF_EXTRACT_IMAGES',
'rag.pdf_loader_mode': 'PDF_LOADER_MODE',
'rag.relevance_threshold': 'RAG_RELEVANCE_THRESHOLD',
'rag.reranking_batch_size': 'RAG_RERANKING_BATCH_SIZE',
'rag.reranking_engine': 'RAG_RERANKING_ENGINE',
'rag.reranking_model': 'RAG_RERANKING_MODEL',
'rag.template': 'RAG_TEMPLATE',
'rag.text_splitter': 'RAG_TEXT_SPLITTER',
'rag.tika_server_url': 'TIKA_SERVER_URL',
'rag.tiktoken_encoding_name': 'TIKTOKEN_ENCODING_NAME',
'rag.top_k': 'RAG_TOP_K',
'rag.top_k_reranker': 'RAG_TOP_K_RERANKER',
# RAG — Web
'rag.web.fetch.max_content_length': 'WEB_FETCH_MAX_CONTENT_LENGTH',
'rag.web.loader.concurrent_requests': 'WEB_LOADER_CONCURRENT_REQUESTS',
'rag.web.loader.engine': 'WEB_LOADER_ENGINE',
'rag.web.loader.external_web_loader_api_key': 'EXTERNAL_WEB_LOADER_API_KEY',
'rag.web.loader.external_web_loader_url': 'EXTERNAL_WEB_LOADER_URL',
'rag.web.loader.firecrawl_api_key': 'FIRECRAWL_API_KEY',
'rag.web.loader.firecrawl_api_url': 'FIRECRAWL_API_BASE_URL',
'rag.web.loader.firecrawl_timeout': 'FIRECRAWL_TIMEOUT',
'rag.web.loader.playwright_timeout': 'PLAYWRIGHT_TIMEOUT',
'rag.web.loader.playwright_ws_url': 'PLAYWRIGHT_WS_URL',
'rag.web.loader.ssl_verification': 'ENABLE_WEB_LOADER_SSL_VERIFICATION',
'rag.web.loader.timeout': 'WEB_LOADER_TIMEOUT',
'rag.web.search.azure_ai_search_api_key': 'AZURE_AI_SEARCH_API_KEY',
'rag.web.search.azure_ai_search_endpoint': 'AZURE_AI_SEARCH_ENDPOINT',
'rag.web.search.azure_ai_search_index_name': 'AZURE_AI_SEARCH_INDEX_NAME',
'rag.web.search.bing_search_v7_endpoint': 'BING_SEARCH_V7_ENDPOINT',
'rag.web.search.bing_search_v7_subscription_key': 'BING_SEARCH_V7_SUBSCRIPTION_KEY',
'rag.web.search.bocha_search_api_key': 'BOCHA_SEARCH_API_KEY',
'rag.web.search.brave_search_api_key': 'BRAVE_SEARCH_API_KEY',
'rag.web.search.brave_search_context_tokens': 'BRAVE_SEARCH_CONTEXT_TOKENS',
'rag.web.search.bypass_embedding_and_retrieval': 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL',
'rag.web.search.bypass_web_loader': 'BYPASS_WEB_SEARCH_WEB_LOADER',
'rag.web.search.concurrent_requests': 'WEB_SEARCH_CONCURRENT_REQUESTS',
'rag.web.search.ddgs_backend': 'DDGS_BACKEND',
'rag.web.search.domain.filter_list': 'WEB_SEARCH_DOMAIN_FILTER_LIST',
'rag.web.search.enable': 'ENABLE_WEB_SEARCH',
'rag.web.search.engine': 'WEB_SEARCH_ENGINE',
'rag.web.search.exa_api_key': 'EXA_API_KEY',
'rag.web.search.external_web_search_api_key': 'EXTERNAL_WEB_SEARCH_API_KEY',
'rag.web.search.external_web_search_url': 'EXTERNAL_WEB_SEARCH_URL',
'rag.web.search.google_pse_api_key': 'GOOGLE_PSE_API_KEY',
'rag.web.search.google_pse_engine_id': 'GOOGLE_PSE_ENGINE_ID',
'rag.web.search.jina_api_base_url': 'JINA_API_BASE_URL',
'rag.web.search.jina_api_key': 'JINA_API_KEY',
'rag.web.search.kagi_search_api_key': 'KAGI_SEARCH_API_KEY',
'rag.web.search.linkup_api_key': 'LINKUP_API_KEY',
'rag.web.search.linkup_search_params': 'LINKUP_SEARCH_PARAMS',
'rag.web.search.mojeek_search_api_key': 'MOJEEK_SEARCH_API_KEY',
'rag.web.search.ollama_cloud_api_key': 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY',
'rag.web.search.perplexity_api_key': 'PERPLEXITY_API_KEY',
'rag.web.search.perplexity_model': 'PERPLEXITY_MODEL',
'rag.web.search.perplexity_search_api_url': 'PERPLEXITY_SEARCH_API_URL',
'rag.web.search.perplexity_search_context_usage': 'PERPLEXITY_SEARCH_CONTEXT_USAGE',
'rag.web.search.result_count': 'WEB_SEARCH_RESULT_COUNT',
'rag.web.search.searchapi_api_key': 'SEARCHAPI_API_KEY',
'rag.web.search.searchapi_engine': 'SEARCHAPI_ENGINE',
'rag.web.search.searxng_language': 'SEARXNG_LANGUAGE',
'rag.web.search.searxng_query_url': 'SEARXNG_QUERY_URL',
'rag.web.search.serpapi_api_key': 'SERPAPI_API_KEY',
'rag.web.search.serpapi_engine': 'SERPAPI_ENGINE',
'rag.web.search.serper_api_key': 'SERPER_API_KEY',
'rag.web.search.serply_api_key': 'SERPLY_API_KEY',
'rag.web.search.serpstack_api_key': 'SERPSTACK_API_KEY',
'rag.web.search.serpstack_https': 'SERPSTACK_HTTPS',
'rag.web.search.sougou_api_sid': 'SOUGOU_API_SID',
'rag.web.search.sougou_api_sk': 'SOUGOU_API_SK',
'rag.web.search.tavily_api_key': 'TAVILY_API_KEY',
'rag.web.search.tavily_extract_depth': 'TAVILY_EXTRACT_DEPTH',
'rag.web.search.trust_env': 'WEB_SEARCH_TRUST_ENV',
'rag.web.search.yacy_password': 'YACY_PASSWORD',
'rag.web.search.yacy_query_url': 'YACY_QUERY_URL',
'rag.web.search.yacy_username': 'YACY_USERNAME',
'rag.web.search.yandex_web_search_api_key': 'YANDEX_WEB_SEARCH_API_KEY',
'rag.web.search.yandex_web_search_config': 'YANDEX_WEB_SEARCH_CONFIG',
'rag.web.search.yandex_web_search_url': 'YANDEX_WEB_SEARCH_URL',
'rag.web.search.youcom_api_key': 'YOUCOM_API_KEY',
'rag.youtube_loader_language': 'YOUTUBE_LOADER_LANGUAGE',
'rag.youtube_loader_proxy_url': 'YOUTUBE_LOADER_PROXY_URL',
# Tasks
'task.autocomplete.enable': 'ENABLE_AUTOCOMPLETE_GENERATION',
'task.autocomplete.input_max_length': 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH',
'task.autocomplete.prompt_template': 'AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE',
'task.follow_up.enable': 'ENABLE_FOLLOW_UP_GENERATION',
'task.follow_up.prompt_template': 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE',
'task.image.prompt_template': 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE',
'task.model.default': 'TASK_MODEL',
'task.model.external': 'TASK_MODEL_EXTERNAL',
'task.query.prompt_template': 'QUERY_GENERATION_PROMPT_TEMPLATE',
'task.query.retrieval.enable': 'ENABLE_RETRIEVAL_QUERY_GENERATION',
'task.query.search.enable': 'ENABLE_SEARCH_QUERY_GENERATION',
'task.tags.enable': 'ENABLE_TAGS_GENERATION',
'task.tags.prompt_template': 'TAGS_GENERATION_PROMPT_TEMPLATE',
'task.title.enable': 'ENABLE_TITLE_GENERATION',
'task.title.prompt_template': 'TITLE_GENERATION_PROMPT_TEMPLATE',
'task.tools.prompt_template': 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE',
'task.voice.prompt.enable': 'ENABLE_VOICE_MODE_PROMPT',
'task.voice.prompt_template': 'VOICE_MODE_PROMPT_TEMPLATE',
# Misc
'terminal_server.connections': 'TERMINAL_SERVER_CONNECTIONS',
'tool_server.connections': 'TOOL_SERVER_CONNECTIONS',
'ui.banners': 'WEBUI_BANNERS',
'ui.default_group_id': 'DEFAULT_GROUP_ID',
'ui.default_locale': 'DEFAULT_LOCALE',
'ui.default_models': 'DEFAULT_MODELS',
'ui.default_pinned_models': 'DEFAULT_PINNED_MODELS',
'ui.default_user_role': 'DEFAULT_USER_ROLE',
'ui.enable_community_sharing': 'ENABLE_COMMUNITY_SHARING',
'ui.enable_login_form': 'ENABLE_LOGIN_FORM',
'ui.enable_message_rating': 'ENABLE_MESSAGE_RATING',
'ui.enable_password_change_form': 'ENABLE_PASSWORD_CHANGE_FORM',
'ui.enable_signup': 'ENABLE_SIGNUP',
'ui.enable_user_webhooks': 'ENABLE_USER_WEBHOOKS',
'ui.model_order_list': 'MODEL_ORDER_LIST',
'ui.pending_user_overlay_content': 'PENDING_USER_OVERLAY_CONTENT',
'ui.pending_user_overlay_title': 'PENDING_USER_OVERLAY_TITLE',
'ui.prompt_suggestions': 'DEFAULT_PROMPT_SUGGESTIONS',
'ui.watermark': 'RESPONSE_WATERMARK',
'user.permissions': 'USER_PERMISSIONS',
'users.enable_status': 'ENABLE_USER_STATUS',
'webhook_url': 'WEBHOOK_URL',
'webui.url': 'WEBUI_URL',
}
STORAGE_KEY_REWRITES = {
'oauth.refresh_token_include_scope': 'oauth.refresh_token.include_scope',
'rag.openai_api_base_url': 'rag.openai.api_base_url',
'rag.openai_api_key': 'rag.openai.api_key',
'rag.ollama.url': 'rag.ollama.base_url',
'rag.ollama.key': 'rag.ollama.api_key',
'oauth.oidc.avatar_claim': 'oauth.picture_claim',
'oauth.oidc.client_id': 'oauth.client_id',
'oauth.oidc.client_secret': 'oauth.client_secret',
'oauth.oidc.code_challenge_method': 'oauth.code_challenge_method',
'oauth.oidc.email_claim': 'oauth.email_claim',
'oauth.oidc.end_session_endpoint': 'oauth.end_session_endpoint',
'oauth.oidc.group_claim': 'oauth.group_claim',
'oauth.oidc.oauth_timeout': 'oauth.timeout',
'oauth.oidc.provider_name': 'oauth.provider_name',
'oauth.oidc.provider_url': 'oauth.provider_url',
'oauth.oidc.redirect_uri': 'oauth.redirect_uri',
'oauth.oidc.scopes': 'oauth.scopes',
'oauth.oidc.sub_claim': 'oauth.sub_claim',
'oauth.oidc.token_endpoint_auth_method': 'oauth.token_endpoint_auth_method',
'oauth.oidc.username_claim': 'oauth.username_claim',
}
LEGACY_KEY_TO_STORAGE_KEY = {
legacy_key: STORAGE_KEY_REWRITES.get(blob_path, blob_path) for blob_path, legacy_key in BLOB_PATH_TO_KEY.items()
}
def _walk_blob(data: dict, prefix: str = '') -> dict:
"""Recursively walk a nested config blob, preserving known config values.
Some config values are intentionally dictionaries, e.g. OPENAI_API_CONFIGS
and OLLAMA_API_CONFIGS. Once the current path is a known config key, keep
that value intact instead of flattening its internals into orphaned rows.
"""
result = {}
for key, value in data.items():
path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}'
if path in BLOB_PATH_TO_KEY or path in LEGACY_KEY_TO_STORAGE_KEY:
result[path] = value
elif isinstance(value, dict):
result.update(_walk_blob(value, path))
else:
result[path] = value
return result
def upgrade() -> None:
"""Reshape config from single-row JSON blob to per-key rows."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
config_columns = (
{column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
)
has_old_config = {'id', 'data'}.issubset(config_columns)
has_new_config = {'key', 'value'}.issubset(config_columns)
# Ad-hoc table reference for reading the old schema
old_config = sa.table(
'config',
sa.column('id', sa.Integer),
sa.column('data', sa.JSON),
)
# 1. Read existing blob
blob_data = {}
if has_old_config:
try:
result = conn.execute(sa.select(old_config.c.data).order_by(old_config.c.id.desc()).limit(1))
row = result.fetchone()
if row and row[0]:
raw = row[0]
blob_data = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
pass # Table might be partially migrated or empty
# 2. Preserve old blob table for rollback/inspection, then create per-key table.
if has_old_config:
if 'config_old' in table_names:
op.drop_table('config_old')
op.rename_table('config', 'config_old')
# 3. Create new per-key table
new_config = (
sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON()),
sa.column('updated_at', sa.BigInteger),
)
if has_new_config
else op.create_table(
'config',
sa.Column('key', sa.Text(), primary_key=True),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
)
# 4. Flatten blob and insert per-key rows
if blob_data:
flat = _walk_blob(blob_data)
# Keep stable dot-notation paths as the database keys.
# Known legacy env-style keys are rewritten to their dotted keys; unknown
# keys are still copied so custom/future config is not silently lost.
rows = {}
for blob_path, value in flat.items():
if blob_path in BLOB_PATH_TO_KEY:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
elif blob_path in LEGACY_KEY_TO_STORAGE_KEY:
storage_key = LEGACY_KEY_TO_STORAGE_KEY[blob_path]
else:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
if storage_key not in rows:
rows[storage_key] = value
# Batch insert via SQLAlchemy table reference
if rows:
now = int(time.time())
op.bulk_insert(
new_config,
[{'key': k, 'value': v, 'updated_at': now} for k, v in rows.items()],
)
def downgrade() -> None:
"""Restore preserved old single-row config table when available."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
if 'config_old' in table_names:
if 'config' in table_names:
op.drop_table('config')
op.rename_table('config_old', 'config')
return
config_columns = (
{column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
)
has_per_key_config = {'key', 'value'}.issubset(config_columns)
blob_data = {}
if has_per_key_config:
config = sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON),
)
for key, value in conn.execute(sa.select(config.c.key, config.c.value)):
blob_data[key] = json.loads(value) if isinstance(value, str) else value
op.drop_table('config')
if 'config' in table_names and not has_per_key_config:
return
old_config = op.create_table(
'config',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=True),
)
if blob_data:
op.bulk_insert(old_config, [{'data': blob_data, 'version': 0}])
@@ -0,0 +1,40 @@
"""add memory path and meta
Revision ID: 42e2978c7933
Revises: 7b3f2a9c1d4e
Create Date: 2026-06-29 05:35:50.565887
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '42e2978c7933'
down_revision: Union[str, None] = '7b3f2a9c1d4e'
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 = {column['name'] for column in inspector.get_columns('memory')}
if 'path' not in columns:
op.add_column('memory', sa.Column('path', sa.Text(), nullable=True))
if 'meta' not in columns:
op.add_column('memory', sa.Column('meta', sa.JSON(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
if 'meta' in columns:
op.drop_column('memory', 'meta')
if 'path' in columns:
op.drop_column('memory', 'path')
@@ -0,0 +1,74 @@
"""add missing primary keys to legacy peewee tables
Revision ID: 461111b60977
Revises: 3c9b0ca343fd
Create Date: 2026-05-14 04:38:14.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '461111b60977'
down_revision: Union[str, None] = '3c9b0ca343fd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Tables bootstrapped by the old Peewee migration layer that may have
# UNIQUE(id) but no PRIMARY KEY constraint. Fresh Alembic installs
# already have correct PKs from 7e5b5dc7342b_init.py.
# 'tag' uses a composite PK since the same tag name can exist for multiple users.
LEGACY_TABLES = {
'auth': ['id'],
'chat': ['id'],
'chatidtag': ['id'],
'document': ['id'],
'file': ['id'],
'function': ['id'],
'memory': ['id'],
'model': ['id'],
'prompt': ['id'],
'tag': ['id', 'user_id'],
'tool': ['id'],
'user': ['id'],
}
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
for table_name, pk_columns in LEGACY_TABLES.items():
if table_name not in existing_tables:
continue
pk = inspector.get_pk_constraint(table_name)
pk_cols = pk.get('constrained_columns', [])
# Already has the correct PK — nothing to do
if sorted(pk_cols) == sorted(pk_columns):
continue
# Check that all PK columns exist
columns = {c['name'] for c in inspector.get_columns(table_name)}
if not all(c in columns for c in pk_columns):
continue
print(f"Promoting UNIQUE(id) -> PRIMARY KEY({', '.join(pk_columns)}) for '{table_name}'")
conn.execute(sa.text(f'DROP TABLE IF EXISTS _alembic_tmp_{table_name}'))
with op.batch_alter_table(table_name) as batch_op:
# Drop existing PK if any (e.g. on wrong column)
if pk_cols and pk.get('name'):
batch_op.drop_constraint(pk['name'], type_='primary')
batch_op.create_primary_key(f'pk_{table_name}', pk_columns)
def downgrade() -> None:
# Downgrade is a no-op — we don't want to remove PKs
pass
@@ -6,8 +6,8 @@ Create Date: 2024-10-23 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '4ace53fd72c8'
down_revision = 'af906e964978'
@@ -16,6 +16,18 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {c['name']: c for c in inspector.get_columns('folder')}
created_at_col = columns.get('created_at')
if not created_at_col:
return
# Only convert if still DateTime — skip if already BigInteger
if isinstance(created_at_col['type'], sa.BigInteger):
return
# Perform safe alterations using batch operation
with op.batch_alter_table('folder', schema=None) as batch_op:
# Step 1: Remove server defaults for created_at and updated_at
@@ -48,20 +60,24 @@ def upgrade():
def downgrade():
# Downgrade: Convert columns back to DateTime and restore defaults
# Convert columns back to DateTime and restore defaults. Mirrors the
# upgrade's postgresql_using cast — without it, Postgres can't
# auto-cast BigInteger → timestamp and aborts with DatatypeMismatch.
with op.batch_alter_table('folder', schema=None) as batch_op:
batch_op.alter_column(
'created_at',
type_=sa.DateTime(),
existing_type=sa.BigInteger(),
existing_nullable=False,
server_default=sa.func.now(), # Restoring server default on downgrade
server_default=sa.func.now(),
postgresql_using='to_timestamp(created_at)::timestamp without time zone',
)
batch_op.alter_column(
'updated_at',
type_=sa.DateTime(),
existing_type=sa.BigInteger(),
existing_nullable=False,
server_default=sa.func.now(), # Restoring server default on downgrade
onupdate=sa.func.now(), # Restoring onupdate behavior if it was there
server_default=sa.func.now(),
onupdate=sa.func.now(),
postgresql_using='to_timestamp(updated_at)::timestamp without time zone',
)
@@ -0,0 +1,37 @@
"""add context summary to chat message
Revision ID: 4c5ce3d2f27f
Revises: 3ff2c63645b8
Create Date: 2026-06-18 23:48:08.310063
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4c5ce3d2f27f'
down_revision: Union[str, None] = '3ff2c63645b8'
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 = {column['name'] for column in inspector.get_columns('chat_message')}
if 'context_summary' not in columns:
op.add_column('chat_message', sa.Column('context_summary', sa.Text(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('chat_message')}
if 'context_summary' in columns:
op.drop_column('chat_message', 'context_summary')
@@ -0,0 +1,88 @@
"""add pinned_note table
Revision ID: 4de81c2a3af1
Revises: 56359461a091
Create Date: 2026-05-09 04:29:27.651341
"""
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '4de81c2a3af1'
down_revision: Union[str, None] = '56359461a091'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
import time
import uuid
from sqlalchemy import insert, select, update
from sqlalchemy.sql import column, table
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'pinned_note' not in existing_tables:
op.create_table(
'pinned_note',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
)
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table(
'pinned_note',
column('id', sa.Text),
column('user_id', sa.Text),
column('note_id', sa.Text),
column('created_at', sa.BigInteger),
)
notes = conn.execute(
select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)
).fetchall()
if notes:
now = int(time.time_ns())
conn.execute(
insert(pinned_note_table),
[
{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now}
for note in notes
],
)
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.drop_column('is_pinned')
def downgrade() -> None:
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_pinned', sa.Boolean(), nullable=True))
conn = op.get_bind()
note_table = table('note', column('id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table('pinned_note', column('note_id', sa.Text))
notes = conn.execute(select(pinned_note_table.c.note_id)).fetchall()
for note in notes:
conn.execute(update(note_table).where(note_table.c.id == note[0]).values(is_pinned=True))
op.drop_table('pinned_note')
@@ -8,9 +8,8 @@ Create Date: 2026-04-19 16:20:58.162045
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '56359461a091'
@@ -19,58 +18,86 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _index_exists(inspector, index_name, table_name):
"""Check if an index already exists on the given table."""
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
op.create_table(
'calendar',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
op.create_table(
'calendar_event',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('calendar_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_at', sa.BigInteger(), nullable=False),
sa.Column('end_at', sa.BigInteger(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('rrule', sa.Text(), nullable=True),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('location', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
if 'calendar' not in tables:
op.create_table(
'calendar',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_table(
'calendar_event_attendee',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
)
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
inspector.clear_cache()
if 'calendar' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_user', 'calendar'):
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
if 'calendar_event' not in tables:
op.create_table(
'calendar_event',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('calendar_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_at', sa.BigInteger(), nullable=False),
sa.Column('end_at', sa.BigInteger(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('rrule', sa.Text(), nullable=True),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('location', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
inspector.clear_cache()
if 'calendar_event' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_event_calendar', 'calendar_event'):
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
if not _index_exists(inspector, 'ix_calendar_event_user_date', 'calendar_event'):
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
if 'calendar_event_attendee' not in tables:
op.create_table(
'calendar_event_attendee',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
)
inspector.clear_cache()
if 'calendar_event_attendee' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_event_attendee_user', 'calendar_event_attendee'):
op.create_index(
'ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False
)
def downgrade() -> None:
@@ -6,8 +6,8 @@ Create Date: 2024-12-22 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '57c599a3cb57'
down_revision = '922e7a387820'
@@ -16,30 +16,36 @@ depends_on = None
def upgrade():
op.create_table(
'channel',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('name', sa.Text()),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
op.create_table(
'message',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('channel_id', sa.Text(), nullable=True),
sa.Column('content', sa.Text()),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
if 'channel' not in existing_tables:
op.create_table(
'channel',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('name', sa.Text()),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
if 'message' not in existing_tables:
op.create_table(
'message',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('channel_id', sa.Text(), nullable=True),
sa.Column('content', sa.Text()),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
def downgrade():
@@ -8,9 +8,9 @@ Create Date: 2025-12-10 15:11:39.424601
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '6283dc0e4d8d'
@@ -20,31 +20,38 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'channel_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_channel_file_channel_id', 'channel_id'),
sa.Index('ix_channel_file_file_id', 'file_id'),
sa.Index('ix_channel_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'channel_file' not in existing_tables:
op.create_table(
'channel_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_channel_file_channel_id', 'channel_id'),
sa.Index('ix_channel_file_file_id', 'file_id'),
sa.Index('ix_channel_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint(
'channel_id', 'file_id', name='uq_channel_file_channel_file'
), # prevent duplicate entries
)
def downgrade() -> None:
@@ -6,11 +6,12 @@ Create Date: 2024-10-01 14:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column, select
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, select, table
revision = '6a39f3d8e55c'
down_revision = 'c0fbf31ca0db'
branch_labels = None
@@ -18,62 +19,67 @@ depends_on = None
def upgrade():
# Creating the 'knowledge' table
print('Creating knowledge table')
knowledge_table = op.create_table(
'knowledge',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
print('Migrating data from document table to knowledge table')
# Representation of the existing 'document' table
document_table = table(
'document',
column('collection_name', sa.String()),
column('user_id', sa.String()),
column('name', sa.String()),
column('title', sa.Text()),
column('content', sa.Text()),
column('timestamp', sa.BigInteger()),
)
# Select all from existing document table
documents = op.get_bind().execute(
select(
document_table.c.collection_name,
document_table.c.user_id,
document_table.c.name,
document_table.c.title,
document_table.c.content,
document_table.c.timestamp,
if 'knowledge' not in existing_tables:
# Creating the 'knowledge' table
print('Creating knowledge table')
knowledge_table = op.create_table(
'knowledge',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
)
# Insert data into knowledge table from document table
for doc in documents:
op.get_bind().execute(
knowledge_table.insert().values(
id=doc.collection_name,
user_id=doc.user_id,
description=doc.name,
meta={
'legacy': True,
'document': True,
'tags': json.loads(doc.content or '{}').get('tags', []),
},
name=doc.title,
created_at=doc.timestamp,
updated_at=doc.timestamp, # using created_at for both created_at and updated_at in project
print('Migrating data from document table to knowledge table')
# Representation of the existing 'document' table
document_table = table(
'document',
column('collection_name', sa.String()),
column('user_id', sa.String()),
column('name', sa.String()),
column('title', sa.Text()),
column('content', sa.Text()),
column('timestamp', sa.BigInteger()),
)
# Select all from existing document table
documents = conn.execute(
select(
document_table.c.collection_name,
document_table.c.user_id,
document_table.c.name,
document_table.c.title,
document_table.c.content,
document_table.c.timestamp,
)
)
# Insert data into knowledge table from document table
for doc in documents:
conn.execute(
knowledge_table.insert().values(
id=doc.collection_name,
user_id=doc.user_id,
description=doc.name,
meta={
'legacy': True,
'document': True,
'tags': json.loads(doc.content or '{}').get('tags', []),
},
name=doc.title,
created_at=doc.timestamp,
updated_at=doc.timestamp,
)
)
def downgrade():
op.drop_table('knowledge')
@@ -6,8 +6,8 @@ Create Date: 2024-12-23 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '7826ab40b532'
down_revision = '57c599a3cb57'
@@ -16,10 +16,15 @@ depends_on = None
def upgrade():
op.add_column(
'file',
sa.Column('access_control', sa.JSON(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
if 'access_control' not in file_cols:
op.add_column(
'file',
sa.Column('access_control', sa.JSON(), nullable=True),
)
def downgrade():
@@ -0,0 +1,44 @@
"""add memory type
Revision ID: 7b3f2a9c1d4e
Revises: 4c5ce3d2f27f
Create Date: 2026-06-25 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '7b3f2a9c1d4e'
down_revision: Union[str, None] = '4c5ce3d2f27f'
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 = {column['name'] for column in inspector.get_columns('memory')}
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'type' not in columns:
op.add_column('memory', sa.Column('type', sa.String(), server_default='context', nullable=False))
if 'ix_memory_type' not in indexes:
op.create_index('ix_memory_type', 'memory', ['type'])
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_type' in indexes:
op.drop_index('ix_memory_type', table_name='memory')
if 'type' in columns:
op.drop_column('memory', 'type')
@@ -1,44 +1,34 @@
"""init
Revision ID: 7e5b5dc7342b
Revises:
Create Date: 2024-06-24 13:15:33.808998
"""
from typing import Sequence, Union
# Initial bootstrap migration version.
# Revision ID: 7e5b5dc7342b
# Revises: (none)
# Created on: 2024-06-24 13:15:33.808998
from __future__ import annotations
from typing import Sequence
import open_webui.internal.db # noqa: F401
import sqlalchemy as sa
from alembic import op
import open_webui.internal.db
from open_webui.internal.db import JSONField
from open_webui.migrations.util import get_existing_tables
# revision identifiers, used by Alembic.
revision: str = '7e5b5dc7342b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
existing_tables = set(get_existing_tables())
# ### commands auto generated by Alembic - please adjust! ###
if 'auth' not in existing_tables:
op.create_table(
'auth',
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Initial schema table declarations
_INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
(
'auth',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('password', sa.Text(), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'chat' not in existing_tables:
op.create_table(
'chat',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'chat',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
@@ -47,24 +37,23 @@ def upgrade() -> None:
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('share_id', sa.Text(), nullable=True),
sa.Column('archived', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('share_id'),
)
if 'chatidtag' not in existing_tables:
op.create_table(
'chatidtag',
],
[sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('share_id')],
),
(
'chatidtag',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('tag_name', sa.String(), nullable=True),
sa.Column('chat_id', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'document' not in existing_tables:
op.create_table(
'document',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'document',
[
sa.Column('collection_name', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
@@ -72,24 +61,23 @@ def upgrade() -> None:
sa.Column('content', sa.Text(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('collection_name'),
sa.UniqueConstraint('name'),
)
if 'file' not in existing_tables:
op.create_table(
'file',
],
[sa.PrimaryKeyConstraint('collection_name'), sa.UniqueConstraint('name')],
),
(
'file',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('filename', sa.Text(), nullable=True),
sa.Column('meta', JSONField(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'function' not in existing_tables:
op.create_table(
'function',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'function',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
@@ -101,23 +89,23 @@ def upgrade() -> None:
sa.Column('is_global', sa.Boolean(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'memory' not in existing_tables:
op.create_table(
'memory',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'memory',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'model' not in existing_tables:
op.create_table(
'model',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'model',
[
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('base_model_id', sa.Text(), nullable=True),
@@ -126,33 +114,33 @@ def upgrade() -> None:
sa.Column('meta', JSONField(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'prompt' not in existing_tables:
op.create_table(
'prompt',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'prompt',
[
sa.Column('command', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('command'),
)
if 'tag' not in existing_tables:
op.create_table(
'tag',
],
[sa.PrimaryKeyConstraint('command')],
),
(
'tag',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('data', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'tool' not in existing_tables:
op.create_table(
'tool',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'tool',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
@@ -162,12 +150,12 @@ def upgrade() -> None:
sa.Column('valves', JSONField(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if 'user' not in existing_tables:
op.create_table(
'user',
],
[sa.PrimaryKeyConstraint('id')],
),
(
'user',
[
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
@@ -180,25 +168,25 @@ def upgrade() -> None:
sa.Column('settings', JSONField(), nullable=True),
sa.Column('info', JSONField(), nullable=True),
sa.Column('oauth_sub', sa.Text(), nullable=True),
],
[
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('api_key'),
sa.UniqueConstraint('oauth_sub'),
)
# ### end Alembic commands ###
],
),
]
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
op.drop_table('tool')
op.drop_table('tag')
op.drop_table('prompt')
op.drop_table('model')
op.drop_table('memory')
op.drop_table('function')
op.drop_table('file')
op.drop_table('document')
op.drop_table('chatidtag')
op.drop_table('chat')
op.drop_table('auth')
# ### end Alembic commands ###
# --- migration execution ---
def upgrade() -> None: # deploy initial schema tables
existing_tables = set(get_existing_tables())
for name, columns, constraints in _INITIAL_TABLES:
if name not in existing_tables:
op.create_table(name, *columns, *constraints)
# --- rollback function ---
def downgrade() -> None: # rollback initial schema tables
for table_name, _, _ in reversed(_INITIAL_TABLES):
op.drop_table(table_name)
@@ -8,9 +8,9 @@ Create Date: 2025-12-10 16:07:58.001282
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '81cc2ce44d79'
@@ -20,20 +20,27 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Add message_id column to channel_file table
with op.batch_alter_table('channel_file', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'message_id',
sa.Text(),
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
nullable=True,
cf_cols = {c['name'] for c in inspector.get_columns('channel_file')}
if 'message_id' not in cf_cols:
with op.batch_alter_table('channel_file', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'message_id',
sa.Text(),
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
nullable=True,
)
)
)
# Add data column to knowledge table
with op.batch_alter_table('knowledge', schema=None) as batch_op:
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
k_cols = {c['name'] for c in inspector.get_columns('knowledge')}
if 'data' not in k_cols:
with op.batch_alter_table('knowledge', schema=None) as batch_op:
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
def downgrade() -> None:
@@ -6,13 +6,13 @@ Create Date: 2026-02-01 04:00:00.000000
"""
import time
import json
import logging
import time
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
log = logging.getLogger(__name__)
@@ -56,6 +56,13 @@ def _flush_batch(conn, table, batch):
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'chat_message' in existing_tables:
return # Already created — skip everything
# Step 1: Create table
op.create_table(
'chat_message',
@@ -85,8 +92,6 @@ def upgrade() -> None:
op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at'])
# Step 2: Backfill from existing chats
conn = op.get_bind()
chat_table = sa.table(
'chat',
sa.column('id', sa.Text()),
@@ -8,9 +8,9 @@ Create Date: 2025-11-30 06:33:38.790341
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '90ef40d4714e'
@@ -20,42 +20,53 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Update 'channel' table
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
if 'is_private' not in channel_cols:
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
if 'archived_at' not in channel_cols:
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
if 'archived_by' not in channel_cols:
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
if 'deleted_at' not in channel_cols:
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
if 'deleted_by' not in channel_cols:
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
if 'updated_by' not in channel_cols:
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
# Update 'channel_member' table
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
if 'role' not in cm_cols:
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
if 'invited_by' not in cm_cols:
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
if 'invited_at' not in cm_cols:
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
# Create 'channel_webhook' table
op.create_table(
'channel_webhook',
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('profile_image_url', sa.Text(), nullable=True),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
pass
if 'channel_webhook' not in existing_tables:
op.create_table(
'channel_webhook',
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('profile_image_url', sa.Text(), nullable=True),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
def downgrade() -> None:
@@ -74,5 +85,3 @@ def downgrade() -> None:
# Drop 'channel_webhook' table
op.drop_table('channel_webhook')
pass
@@ -6,8 +6,8 @@ Create Date: 2024-11-14 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '922e7a387820'
down_revision = '4ace53fd72c8'
@@ -16,70 +16,55 @@ depends_on = None
def upgrade():
op.create_table(
'group',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('permissions', sa.JSON(), nullable=True),
sa.Column('user_ids', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'group' not in existing_tables:
op.create_table(
'group',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('permissions', sa.JSON(), nullable=True),
sa.Column('user_ids', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# Add 'access_control' column to 'model' table
op.add_column(
'model',
sa.Column('access_control', sa.JSON(), nullable=True),
)
# Add 'is_active' column to 'model' table
op.add_column(
'model',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
server_default=sa.sql.expression.true(),
),
)
model_cols = {c['name'] for c in inspector.get_columns('model')}
if 'access_control' not in model_cols:
op.add_column('model', sa.Column('access_control', sa.JSON(), nullable=True))
if 'is_active' not in model_cols:
op.add_column(
'model',
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
)
# Add 'access_control' column to 'knowledge' table
op.add_column(
'knowledge',
sa.Column('access_control', sa.JSON(), nullable=True),
)
knowledge_cols = {c['name'] for c in inspector.get_columns('knowledge')}
if 'access_control' not in knowledge_cols:
op.add_column('knowledge', sa.Column('access_control', sa.JSON(), nullable=True))
# Add 'access_control' column to 'prompt' table
op.add_column(
'prompt',
sa.Column('access_control', sa.JSON(), nullable=True),
)
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'access_control' not in prompt_cols:
op.add_column('prompt', sa.Column('access_control', sa.JSON(), nullable=True))
# Add 'access_control' column to 'tools' table
op.add_column(
'tool',
sa.Column('access_control', sa.JSON(), nullable=True),
)
tool_cols = {c['name'] for c in inspector.get_columns('tool')}
if 'access_control' not in tool_cols:
op.add_column('tool', sa.Column('access_control', sa.JSON(), nullable=True))
def downgrade():
op.drop_table('group')
# Drop 'access_control' column from 'model' table
op.drop_column('model', 'access_control')
# Drop 'is_active' column from 'model' table
op.drop_column('model', 'is_active')
# Drop 'access_control' column from 'knowledge' table
op.drop_column('knowledge', 'access_control')
# Drop 'access_control' column from 'prompt' table
op.drop_column('prompt', 'access_control')
# Drop 'access_control' column from 'tools' table
op.drop_column('tool', 'access_control')
@@ -6,8 +6,8 @@ Create Date: 2025-05-03 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = '9f0c9cd09105'
down_revision = '3781e22d8b01'
@@ -16,17 +16,22 @@ depends_on = None
def upgrade():
op.create_table(
'note',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'note' not in existing_tables:
op.create_table(
'note',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
def downgrade():
@@ -0,0 +1,28 @@
"""Add memory user_id index
Revision ID: a0b1c2d3e4f5
Revises: 4de81c2a3af1
Create Date: 2025-09-15 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = 'a0b1c2d3e4f5'
down_revision = '4de81c2a3af1'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_indexes = {idx['name'] for idx in inspector.get_indexes('memory')}
if 'ix_memory_user_id' not in existing_indexes:
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
def downgrade():
op.drop_index('ix_memory_user_id', table_name='memory')
@@ -8,9 +8,8 @@ Create Date: 2026-02-11 09:30:00.000000
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
from open_webui.migrations.util import get_existing_tables
revision: str = 'a1b2c3d4e5f6'
@@ -8,8 +8,8 @@ Create Date: 2026-03-29 22:15:00.000000
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a3dd5bedd151'
@@ -19,8 +19,14 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'tasks' not in columns:
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
if 'summary' not in columns:
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
def downgrade() -> None:
@@ -8,8 +8,8 @@ Create Date: 2025-09-27 02:24:18.058455
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a5c220713937'
@@ -19,16 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
msg_cols = {c['name'] for c in inspector.get_columns('message')}
# Add 'reply_to_id' column to the 'message' table for replying to messages
op.add_column(
'message',
sa.Column('reply_to_id', sa.Text(), nullable=True),
)
pass
if 'reply_to_id' not in msg_cols:
op.add_column(
'message',
sa.Column('reply_to_id', sa.Text(), nullable=True),
)
def downgrade() -> None:
# Remove 'reply_to_id' column from the 'message' table
op.drop_column('message', 'reply_to_id')
pass
@@ -6,8 +6,8 @@ Create Date: 2024-10-20 17:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
# Revision identifiers, used by Alembic.
revision = 'af906e964978'
@@ -17,23 +17,28 @@ depends_on = None
def upgrade():
# ### Create feedback table ###
op.create_table(
'feedback',
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
sa.Column(
'created_at', sa.BigInteger(), nullable=False
), # Feedback creation timestamp (BIGINT representing epoch)
sa.Column(
'updated_at', sa.BigInteger(), nullable=False
), # Feedback update timestamp (BIGINT representing epoch)
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'feedback' not in existing_tables:
# ### Create feedback table ###
op.create_table(
'feedback',
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
sa.Column(
'created_at', sa.BigInteger(), nullable=False
), # Feedback creation timestamp (BIGINT representing epoch)
sa.Column(
'updated_at', sa.BigInteger(), nullable=False
), # Feedback update timestamp (BIGINT representing epoch)
)
def downgrade():
@@ -6,15 +6,13 @@ Create Date: 2025-11-28 04:55:31.737538
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import json
import time
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b10670c03dd5'
@@ -22,20 +20,49 @@ down_revision: Union[str, None] = '2f1211949ecc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# ── Ad-hoc table references for Core DML ─────────────────────────────────
# These are lightweight table() / column() references used only inside this
# migration for SELECT / UPDATE / INSERT — they do NOT create or alter
# anything on disk.
_user = sa.table(
'user',
sa.column('id', sa.Text),
sa.column('oauth_sub', sa.Text),
sa.column('oauth', sa.JSON),
sa.column('api_key', sa.Text),
sa.column('info', sa.Text),
sa.column('settings', sa.Text),
)
_api_key = sa.table(
'api_key',
sa.column('id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('key', sa.Text),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
)
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
"""
SQLite requires manual removal of any indexes referencing a column
before ALTER TABLE ... DROP COLUMN can succeed.
SQLite requires manual removal of any user-created indexes referencing
a column before ALTER TABLE ... DROP COLUMN can succeed.
NOTE: PRAGMAs have no Core equivalent — raw text is unavoidable here.
"""
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
for idx in indexes:
index_name = idx[1] # index name
# Get indexed columns
# Skip system-managed autoindexes (PK / UNIQUE constraints) — they
# cannot be dropped directly and will disappear when the column is
# removed via batch_alter_table.
if index_name.startswith('sqlite_autoindex_'):
continue
idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall()
indexed_cols = [row[2] for row in idx_info] # col names
indexed_cols = [row[2] for row in idx_info]
if column_name in indexed_cols:
conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}'))
@@ -44,33 +71,30 @@ def _convert_column_to_json(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.Text))
# SQLite cannot ALTER COLUMN → must recreate column
if dialect == 'sqlite':
# 1. Add temporary column
op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True))
# 2. Load old data
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
for row in rows:
uid, raw = row
for uid, raw in rows:
if raw is None:
parsed = None
else:
try:
parsed = json.loads(raw)
except Exception:
parsed = None # fallback safe behavior
parsed = None
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
{'val': json.dumps(parsed) if parsed else None, 'id': uid},
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_json', sa.JSON)))
.where(sa.column('id') == uid)
.values({f'{column}_json': parsed})
)
# 3. Drop old TEXT column
op.drop_column(table, column)
# 4. Rename new JSON column → original name
op.alter_column(table, f'{column}_json', new_column_name=column)
else:
@@ -87,15 +111,18 @@ def _convert_column_to_text(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.JSON))
if dialect == 'sqlite':
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
for uid, raw in rows:
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
{'val': json.dumps(raw) if raw else None, 'id': uid},
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_text', sa.Text)))
.where(sa.column('id') == uid)
.values({f'{column}_text': json.dumps(raw) if raw is not None else None})
)
op.drop_column(table, column)
@@ -111,88 +138,93 @@ def _convert_column_to_text(table: str, column: str):
def upgrade() -> None:
op.add_column('user', sa.Column('profile_banner_image_url', sa.Text(), nullable=True))
op.add_column('user', sa.Column('timezone', sa.String(), nullable=True))
op.add_column('user', sa.Column('presence_state', sa.String(), nullable=True))
op.add_column('user', sa.Column('status_emoji', sa.String(), nullable=True))
op.add_column('user', sa.Column('status_message', sa.Text(), nullable=True))
op.add_column('user', sa.Column('status_expires_at', sa.BigInteger(), nullable=True))
op.add_column('user', sa.Column('oauth', sa.JSON(), nullable=True))
# Convert info (TEXT/JSONField) → JSON
_convert_column_to_json('user', 'info')
# Convert settings (TEXT/JSONField) → JSON
_convert_column_to_json('user', 'settings')
op.create_table(
'api_key',
sa.Column('id', sa.Text(), primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
sa.Column('key', sa.Text(), unique=True, nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.BigInteger(), nullable=True),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
conn = op.get_bind()
users = conn.execute(sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')).fetchall()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
user_columns = {c['name'] for c in inspector.get_columns('user')}
for uid, oauth_sub in users:
if oauth_sub:
# Example formats supported:
# provider@sub
# plain sub (stored as {"oidc": {"sub": sub}})
if '@' in oauth_sub:
provider, sub = oauth_sub.split('@', 1)
else:
provider, sub = 'oidc', oauth_sub
# ── Add new columns (idempotent) ──────────────────────────────────
for col_name, col_type in [
('profile_banner_image_url', sa.Text()),
('timezone', sa.String()),
('presence_state', sa.String()),
('status_emoji', sa.String()),
('status_message', sa.Text()),
('status_expires_at', sa.BigInteger()),
('oauth', sa.JSON()),
]:
if col_name not in user_columns:
op.add_column('user', sa.Column(col_name, col_type, nullable=True))
oauth_json = json.dumps({provider: {'sub': sub}})
conn.execute(
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
{'oauth': oauth_json, 'id': uid},
)
# Convert info (TEXT/JSONField) → JSON (skip if already JSON)
user_col_types = {c['name']: c['type'] for c in inspector.get_columns('user')}
if isinstance(user_col_types.get('info'), sa.Text):
_convert_column_to_json('user', 'info')
# Convert settings (TEXT/JSONField) → JSON (skip if already JSON)
if isinstance(user_col_types.get('settings'), sa.Text):
_convert_column_to_json('user', 'settings')
users_with_keys = conn.execute(sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')).fetchall()
now = int(time.time())
# ── Create api_key table (idempotent) ─────────────────────────────
if 'api_key' not in existing_tables:
op.create_table(
'api_key',
sa.Column('id', sa.Text(), primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
sa.Column('key', sa.Text(), unique=True, nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.BigInteger(), nullable=True),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
for uid, api_key in users_with_keys:
if api_key:
conn.execute(
sa.text("""
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
VALUES (:id, :user_id, :key, :created_at, :updated_at)
"""),
{
'id': f'key_{uid}',
'user_id': uid,
'key': api_key,
'created_at': now,
'updated_at': now,
},
)
# ── Migrate oauth_sub → oauth JSON (only if old column still exists)
if 'oauth_sub' in user_columns:
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth_sub).where(_user.c.oauth_sub.is_not(None))).fetchall()
if conn.dialect.name == 'sqlite':
_drop_sqlite_indexes_for_column('user', 'api_key', conn)
_drop_sqlite_indexes_for_column('user', 'oauth_sub', conn)
for uid, oauth_sub in rows:
if oauth_sub:
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
conn.execute(
sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}}))
)
with op.batch_alter_table('user') as batch_op:
batch_op.drop_column('api_key')
batch_op.drop_column('oauth_sub')
# ── Migrate api_key column → api_key table (only if old column still exists)
if 'api_key' in user_columns:
rows = conn.execute(sa.select(_user.c.id, _user.c.api_key).where(_user.c.api_key.is_not(None))).fetchall()
now = int(time.time())
for uid, key_val in rows:
if key_val:
conn.execute(
sa.insert(_api_key).values(
id=f'key_{uid}',
user_id=uid,
key=key_val,
created_at=now,
updated_at=now,
)
)
# ── Drop legacy columns (idempotent) ──────────────────────────────
cols_to_drop = {'api_key', 'oauth_sub'} & user_columns
if cols_to_drop:
if conn.dialect.name == 'sqlite':
for col in cols_to_drop:
_drop_sqlite_indexes_for_column('user', col, conn)
with op.batch_alter_table('user') as batch_op:
for col in cols_to_drop:
batch_op.drop_column(col)
def downgrade() -> None:
# --- 1. Restore old oauth_sub column ---
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
conn = op.get_bind()
users = conn.execute(sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')).fetchall()
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
for uid, oauth in users:
for uid, oauth in rows:
try:
data = json.loads(oauth)
provider = list(data.keys())[0]
@@ -201,25 +233,17 @@ def downgrade() -> None:
except Exception:
oauth_sub = None
conn.execute(
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
{'oauth_sub': oauth_sub, 'id': uid},
)
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth_sub=oauth_sub))
op.drop_column('user', 'oauth')
# --- 2. Restore api_key field ---
# --- Restore api_key field ---
op.add_column('user', sa.Column('api_key', sa.String(), nullable=True))
# Restore values from api_key
keys = conn.execute(sa.text('SELECT user_id, key FROM api_key')).fetchall()
keys = conn.execute(sa.select(_api_key.c.user_id, _api_key.c.key)).fetchall()
for uid, key in keys:
conn.execute(
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
{'key': key, 'id': uid},
)
conn.execute(sa.update(_user).where(_user.c.id == uid).values(api_key=key))
# Drop new table
op.drop_table('api_key')
with op.batch_alter_table('user') as batch_op:
@@ -8,8 +8,8 @@ Create Date: 2026-02-13 14:19:00.000000
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
@@ -19,7 +19,12 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
user_cols = {c['name'] for c in inspector.get_columns('user')}
if 'scim' not in user_cols:
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
def downgrade() -> None:
@@ -6,9 +6,8 @@ Create Date: 2026-04-01 04:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'b7c8d9e0f1a2'
@@ -18,9 +17,14 @@ depends_on = None
def upgrade():
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
# Set existing chats to be marked as read
op.execute('UPDATE chat SET last_read_at = updated_at')
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'last_read_at' not in columns:
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
# Set existing chats to be marked as read
op.execute('UPDATE chat SET last_read_at = updated_at')
def downgrade():
@@ -19,10 +19,17 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
if 'hash' not in file_cols:
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
if 'data' not in file_cols:
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
if 'updated_at' not in file_cols:
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
def downgrade():
@@ -9,8 +9,8 @@ Create Date: 2026-04-16 23:00:00.000000
import time
import uuid
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = 'c1d2e3f4a5b6'
down_revision = 'e1f2a3b4c5d6'
@@ -61,18 +61,21 @@ access_grant_t = sa.table(
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
# 1. Create shared_chat table
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 1. Create shared_chat table (idempotent)
if 'shared_chat' not in tables:
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 2. Migrate existing shared-* rows
shared_rows = conn.execute(
@@ -96,31 +99,51 @@ def upgrade():
if not original:
continue
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
# Check if shared_chat record already exists (idempotent)
existing_shared = conn.execute(
sa.select(shared_chat_t.c.id).where(shared_chat_t.c.id == share_token)
).fetchone()
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
if not existing_shared:
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
# Check if access_grant record already exists (idempotent)
existing_grant = conn.execute(
sa.select(access_grant_t.c.id).where(
sa.and_(
access_grant_t.c.resource_type == 'shared_chat',
access_grant_t.c.resource_id == original_chat_id,
access_grant_t.c.principal_type == 'user',
access_grant_t.c.principal_id == '*',
access_grant_t.c.permission == 'read',
)
)
).fetchone()
if not existing_grant:
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
)
)
)
# 3. Clean up old phantom rows
conn.execute(
@@ -6,11 +6,12 @@ Create Date: 2024-10-20 17:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
import json
from sqlalchemy.sql import table, column
from sqlalchemy import String, Text, JSON, and_
import sqlalchemy as sa
from alembic import op
from sqlalchemy import JSON, String, Text, and_
from sqlalchemy.sql import column, table
revision = 'c29facfe716b'
down_revision = 'c69f45358db4'
@@ -19,8 +20,13 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
# 1. Add the `path` column to the "file" table.
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
if 'path' not in file_cols:
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
# 2. Convert the `meta` column from Text/JSONField to `JSON()`
# Use Alembic's default batch_op for dialect compatibility.
@@ -8,8 +8,8 @@ Create Date: 2025-12-21 20:27:41.694897
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c440947495f3'
@@ -19,36 +19,39 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'chat_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'chat_id',
sa.Text(),
sa.ForeignKey('chat.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('message_id', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_chat_file_chat_id', 'chat_id'),
sa.Index('ix_chat_file_file_id', 'file_id'),
sa.Index('ix_chat_file_message_id', 'message_id'),
sa.Index('ix_chat_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries
)
pass
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'chat_file' not in existing_tables:
op.create_table(
'chat_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'chat_id',
sa.Text(),
sa.ForeignKey('chat.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('message_id', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_chat_file_chat_id', 'chat_id'),
sa.Index('ix_chat_file_file_id', 'file_id'),
sa.Index('ix_chat_file_message_id', 'message_id'),
sa.Index('ix_chat_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries
)
def downgrade() -> None:
op.drop_table('chat_file')
pass
@@ -6,8 +6,8 @@ Create Date: 2024-10-16 02:02:35.241684
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = 'c69f45358db4'
down_revision = '3ab32c4b8f59'
@@ -16,30 +16,37 @@ depends_on = None
def upgrade():
op.create_table(
'folder',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('items', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
sa.PrimaryKeyConstraint('id', 'user_id'),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
op.add_column(
'chat',
sa.Column('folder_id', sa.Text(), nullable=True),
)
if 'folder' not in existing_tables:
op.create_table(
'folder',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('items', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
sa.PrimaryKeyConstraint('id', 'user_id'),
)
chat_cols = {c['name'] for c in inspector.get_columns('chat')}
if 'folder_id' not in chat_cols:
op.add_column(
'chat',
sa.Column('folder_id', sa.Text(), nullable=True),
)
def downgrade():
@@ -1,9 +1,8 @@
"""Add config table
"""Add config table.
Revision ID: ca81bd47c050
Revises: 7e5b5dc7342b
Create Date: 2024-08-25 15:26:35.241684
"""
from typing import Sequence, Union
@@ -11,29 +10,40 @@ from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'ca81bd47c050'
down_revision: Union[str, None] = '7e5b5dc7342b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
op.create_table(
'config',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer, nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
)
def upgrade() -> None:
"""Create a key-value config table with versioning."""
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'config' not in existing_tables:
op.create_table(
'config',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer, nullable=False),
sa.Column(
'created_at',
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
)
def downgrade():
def downgrade() -> None:
"""Drop the config table."""
op.drop_table('config')
@@ -6,8 +6,8 @@ Create Date: 2025-07-13 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = 'd31026856c01'
down_revision = '9f0c9cd09105'
@@ -16,7 +16,12 @@ depends_on = None
def upgrade():
op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
folder_cols = {c['name'] for c in inspector.get_columns('folder')}
if 'data' not in folder_cols:
op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True))
def downgrade():
@@ -7,8 +7,8 @@ Create Date: 2026-03-30
from typing import Union
from alembic import op
import sqlalchemy as sa
from alembic import op
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, None] = 'a3dd5bedd151'
@@ -16,36 +16,56 @@ branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'automation',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
def _index_exists(inspector, index_name, table_name):
"""Check if an index already exists on the given table (works for both SQLite and PostgreSQL)."""
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
op.create_table(
'automation_run',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('automation_id', sa.Text(), nullable=False),
sa.Column('chat_id', sa.Text(), nullable=True),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
op.create_index(
'ix_automation_run_automation_id',
'automation_run',
['automation_id'],
)
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if 'automation' not in tables:
op.create_table(
'automation',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
inspector.clear_cache()
if 'automation' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_automation_next_run', 'automation'):
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
if 'automation_run' not in tables:
op.create_table(
'automation_run',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('automation_id', sa.Text(), nullable=False),
sa.Column('chat_id', sa.Text(), nullable=True),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
inspector.clear_cache()
if 'automation_run' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_automation_run_automation_id', 'automation_run'):
op.create_index(
'ix_automation_run_automation_id',
'automation_run',
['automation_id'],
)
def downgrade():
@@ -6,8 +6,8 @@ Create Date: 2026-04-14 22:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = 'e1f2a3b4c5d6'
down_revision = 'b7c8d9e0f1a2'
@@ -16,7 +16,12 @@ depends_on = None
def upgrade():
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('note')]
if 'is_pinned' not in columns:
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
def downgrade():
@@ -11,13 +11,12 @@ Access control semantics:
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
"""
from typing import Sequence, Union
import time
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
from open_webui.migrations.util import get_existing_tables
revision: str = 'f1e2d3c4b5a6'
@@ -81,13 +80,18 @@ def upgrade() -> None:
if table_name not in existing_tables:
continue
# Query all rows
try:
result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"'))
rows = result.fetchall()
except Exception:
# Check if access_control and id columns exist (may already be dropped on re-run,
# or table may have been rebuilt without id during intermediate migration states)
insp = sa.inspect(conn)
insp.clear_cache() # Ensure fresh metadata after prior migrations that rebuild tables
table_cols = {c['name'] for c in insp.get_columns(table_name)}
if 'access_control' not in table_cols or 'id' not in table_cols:
continue
# Query all rows
result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"'))
rows = result.fetchall()
for row in rows:
resource_id = row[0]
access_control_json = row[1]
@@ -208,15 +212,15 @@ def upgrade() -> None:
except Exception:
pass
# Drop access_control columns from resource tables
# Drop access_control columns from resource tables (only if column still exists)
inspector = sa.inspect(conn)
for table_name, _ in resource_tables:
if table_name not in existing_tables:
continue
try:
cols = {c['name'] for c in inspector.get_columns(table_name)}
if 'access_control' in cols:
with op.batch_alter_table(table_name) as batch:
batch.drop_column('access_control')
except Exception:
pass
def downgrade() -> None:
+3 -5
View File
@@ -3,13 +3,11 @@ import time
import uuid
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, and_, delete, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -623,8 +621,8 @@ class AccessGrantsTable:
Get all users who have the specified permission on a resource.
Returns a list of UserModel instances.
"""
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel, Users
async with get_async_db_context(db) as db:
result = await db.execute(
+145 -118
View File
@@ -1,50 +1,56 @@
"""Auth credential models and data-access layer."""
from __future__ import annotations
import logging
import uuid
from typing import Optional
from sqlalchemy import select, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
import bcrypt
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, field_validator
from sqlalchemy import Boolean, Column, String, Text
from sqlalchemy import Boolean, Column, String, Text, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
####################
# DB MODEL
####################
# Pre-computed hash verified on signin paths that lack a real credential
# (unknown user, inactive account) so response timing cannot reveal
# whether an account exists (CWE-208).
PLACEHOLDER_HASH = bcrypt.hashpw(b'placeholder', bcrypt.gensalt()).decode('utf-8')
class Auth(Base):
class Auth(Base): # credential ↔ user linkage
"""Maps a user ID to an email/password pair with an active flag."""
__tablename__ = 'auth'
id = Column(String, primary_key=True, unique=True)
email = Column(String)
password = Column(Text)
active = Column(Boolean)
id = Column(String, primary_key=True, unique=True) # mirrors User.id
email = Column(String) # login address, kept in sync with User.email
password = Column(Text) # argon2 / bcrypt hash
active = Column(Boolean) # account soft-disable toggle
class AuthModel(BaseModel):
"""Pydantic mirror of the ``auth`` table row."""
id: str
email: str
password: str
active: bool = True
####################
# Forms
####################
class Token(BaseModel):
"""JWT bearer-token response wrapper."""
token: str
token_type: str
class ApiKey(BaseModel):
api_key: Optional[str] = None
api_key: str | None = None
class SigninResponse(Token, UserProfileImageResponse):
@@ -74,21 +80,26 @@ class SignupForm(BaseModel):
name: str
email: str
password: str
profile_image_url: Optional[str] = '/user.png'
profile_image_url: str | None = '/user.png'
@field_validator('profile_image_url')
@classmethod
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
def check_profile_image_url(cls, v: str | None) -> str | None:
if v is not None:
return validate_profile_image_url(v)
return v
class AddUserForm(SignupForm):
role: Optional[str] = 'pending'
role: str | None = 'pending'
# --- data-access layer ---
class AuthsTable:
"""Provides CRUD operations for the Auth ↔ User lifecycle."""
async def insert_new_auth(
self,
email: str,
@@ -96,117 +107,133 @@ class AuthsTable:
name: str,
profile_image_url: str = '/user.png',
role: str = 'pending',
oauth: Optional[dict] = None,
db: Optional[AsyncSession] = None,
) -> Optional[UserModel]:
async with get_async_db_context(db) as db:
oauth: dict | None = None,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Create an Auth + User pair inside a single transaction."""
async with get_async_db_context(db) as session:
log.info('insert_new_auth')
id = str(uuid.uuid4())
new_id = str(uuid.uuid4())
auth = AuthModel(**{'id': id, 'email': email, 'password': password, 'active': True})
result = Auth(**auth.model_dump())
db.add(result)
credential = Auth(
id=new_id,
email=email,
password=password,
active=True,
)
session.add(credential)
user = await Users.insert_new_user(id, name, email, profile_image_url, role, oauth=oauth, db=db)
await db.commit()
await db.refresh(result)
if result and user:
return user
else:
return None
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)
return created_user if credential and created_user else None
async def authenticate_user(
self, email: str, verify_password: callable, db: Optional[AsyncSession] = None
) -> Optional[UserModel]:
log.info(f'authenticate_user: {email}')
user = await Users.get_user_by_email(email, db=db)
if not user:
return None
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Auth).filter_by(id=user.id, active=True))
auth = result.scalars().first()
if auth:
if verify_password(auth.password):
return user
else:
return None
else:
return None
except Exception:
return None
self,
email: str,
verify_password: callable,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Verify email + password credentials and return the matching user."""
log.info('authenticate_user: %s', email)
resolved = await Users.get_user_by_email(email, db=db)
if not resolved:
await verify_password(PLACEHOLDER_HASH)
return
# load the credential row and verify the password hash
async with get_async_db_context(db) as session:
credential = await session.get(Auth, resolved.id)
if not credential or not credential.active:
await verify_password(PLACEHOLDER_HASH)
return
if not await verify_password(credential.password):
return
return resolved
async def authenticate_user_by_api_key(
self, api_key: str, db: Optional[AsyncSession] = None
) -> Optional[UserModel]:
log.info(f'authenticate_user_by_api_key')
# if no api_key, return None
self,
api_key: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up the user that owns the given API key."""
log.info('authenticate_user_by_api_key')
if not api_key:
return None
return
# delegate to the Users model for the actual lookup
return await Users.get_user_by_api_key(api_key, db=db)
try:
user = await Users.get_user_by_api_key(api_key, db=db)
return user if user else None
except Exception:
return False
async def authenticate_user_by_email(
self,
email: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Single-query auth via JOIN on Auth ↔ User, filtered by active flag."""
log.info('authenticate_user_by_email: %s', email)
# single JOIN avoids N+1 — returns (Auth, User) tuple or None
async with get_async_db_context(db) as session:
joined_query = (
select(Auth, User).join(User, Auth.id == User.id).where(Auth.email == email, Auth.active.is_(True))
)
match = (await session.execute(joined_query)).first()
if not match:
return
_, found_user = match
return UserModel.model_validate(found_user)
async def authenticate_user_by_email(self, email: str, db: Optional[AsyncSession] = None) -> Optional[UserModel]:
log.info(f'authenticate_user_by_email: {email}')
try:
async with get_async_db_context(db) as db:
# Single JOIN query instead of two separate queries
result = await db.execute(
select(Auth, User).join(User, Auth.id == User.id).filter(Auth.email == email, Auth.active == True)
)
row = result.first()
if row:
_, user = row
return UserModel.model_validate(user)
return None
except Exception:
return None
async def update_user_password_by_id(self, id: str, new_password: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
result = await db.execute(update(Auth).filter_by(id=id).values(password=new_password))
await db.commit()
return True if result.rowcount == 1 else False
except Exception:
return False
async def update_email_by_id(self, id: str, email: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
result = await db.execute(update(Auth).filter_by(id=id).values(email=email))
await db.commit()
if result.rowcount == 1:
await Users.update_user_by_id(id, {'email': email}, db=db)
return True
async def update_email_by_id(
self,
user_id: str,
email: str,
db: AsyncSession | None = None,
) -> bool:
"""Set a new email on the auth record and propagate to the user row."""
async with get_async_db_context(db) as session:
auth_row = await session.get(Auth, user_id)
if auth_row is None:
return False
except Exception:
return False
auth_row.email = email
await session.commit()
await Users.update_user_by_id(user_id, {'email': email}, db=session)
return True
# --- password modification ---
async def delete_auth_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
# Delete User
result = await Users.delete_user_by_id(id, db=db)
async def update_user_password_by_id(
self,
user_id: str,
new_password: str,
db: AsyncSession | None = None,
) -> bool:
"""Set a new password hash for an existing user."""
async with get_async_db_context(db) as session:
auth_row = await session.get(Auth, user_id)
if auth_row is None:
return False
auth_row.password = new_password
await session.commit()
return True
if result:
await db.execute(delete(Auth).filter_by(id=id))
await db.commit()
return True
else:
return False
except Exception:
return False
async def delete_auth_by_id(
self,
id: str,
db: AsyncSession | None = None,
) -> bool:
"""Remove a user and their auth credential in one transaction."""
async with get_async_db_context(db) as session:
if not await Users.delete_user_by_id(id, db=session):
return False
await session.execute(delete(Auth).where(Auth.id == id))
await session.commit()
return True
Auths = AuthsTable()
Auths = AuthsTable() # singleton — module-level instance
+4 -5
View File
@@ -1,13 +1,12 @@
import time
import logging
import time
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
+33 -25
View File
@@ -1,30 +1,29 @@
import time
import logging
import time
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
Column,
Text,
JSON,
Boolean,
BigInteger,
Index,
UniqueConstraint,
select,
or_,
exists,
func,
delete,
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
Index,
Text,
UniqueConstraint,
delete,
exists,
func,
or_,
select,
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -395,14 +394,16 @@ class CalendarTable:
# Delete events
await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id))
# Delete access grants
await AccessGrants.revoke_all_access('calendar', id, db=db)
# Delete calendar
await db.execute(delete(Calendar).filter(Calendar.id == id))
await db.commit()
return True
except Exception:
# Revoke access grants in a separate transaction to avoid
# write-lock contention on SQLite when session sharing is off.
await AccessGrants.revoke_all_access('calendar', id)
return True
except Exception as e:
log.exception(f'Failed to delete calendar {id}: {e}')
return False
@@ -694,12 +695,19 @@ class CalendarEventTable:
self,
now_ns: int,
default_lookahead_ns: int,
grace_ns: int = 0,
db: Optional[AsyncSession] = None,
) -> list[tuple[CalendarEventModel, Optional[str]]]:
"""Events starting between now and now + lookahead, for alert processing.
Per-event lookahead is read from meta.alert_minutes (falls back to
default_lookahead_ns). Returns (event, user_timezone) pairs.
*grace_ns* widens the SQL lower bound so that events whose start_at
is up to *grace_ns* nanoseconds in the past are still fetched. This
ensures "At time of event" alerts (alert_minutes=0) are not missed
when the scheduler polls a few seconds after the event's exact start
time.
"""
from open_webui.models.users import User as UserRow
@@ -714,7 +722,7 @@ class CalendarEventTable:
.outerjoin(UserRow, UserRow.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
CalendarEvent.start_at >= now_ns,
CalendarEvent.start_at >= now_ns - grace_ns,
CalendarEvent.start_at <= upper,
)
)

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