Compare commits

...
397 Commits
Author SHA1 Message Date
Tim Baek 7c7fe44328 Merge pull request #21443 from open-webui/dev
0.8.2
2026-02-16 01:37:13 -06:00
Algorithm5838 15b5f97f89 fix: prevent scroll jump when editing large messages (#21402)
Save and restore scroll positions around textarea auto-resize to avoid
layout shifts. Use preventScroll on focus.
2026-02-16 01:33:51 -06:00
Shamil ca0983f76b i18n: Add missing Russian (ru-RU) translations (#21453)
* i18n: add missing Russian (ru-RU) translations

* i18n: add translations for API Keys and Asc keys
2026-02-16 01:27:23 -06:00
Timothy Jaeryang Baek ef04a704ce chore: format 2026-02-16 01:26:29 -06:00
Timothy Jaeryang Baek 5fda814669 doc: changelog 2026-02-16 01:25:40 -06:00
Timothy Jaeryang Baek c26e8110af enh: renderMarkdownInPreviews 2026-02-16 01:23:56 -06:00
Timothy Jaeryang Baek 9e85055b8b doc: changelog 2026-02-16 00:55:23 -06:00
Timothy Jaeryang Baek 6d17de6c67 chore: bump 2026-02-16 00:53:36 -06:00
Timothy Jaeryang Baek f4e99c80f6 refac: "tool_calls" finish reason support 2026-02-16 00:53:01 -06:00
Timothy Jaeryang Baek 09dc28df1e chore: format 2026-02-16 00:43:32 -06:00
Timothy Jaeryang Baek c748c3ede7 refac 2026-02-16 00:41:36 -06:00
Timothy Jaeryang Baek 33308022f0 refac 2026-02-15 23:57:40 -06:00
Timothy Jaeryang Baek f96e8f04fc refac: styling 2026-02-15 23:50:34 -06:00
Timothy Jaeryang Baek 38ae91ae23 refac 2026-02-15 23:35:40 -06:00
Timothy Jaeryang Baek 88401e91c7 refac 2026-02-15 23:28:47 -06:00
Timothy Jaeryang Baek 8c5cfa530d refac: styling 2026-02-15 22:55:50 -06:00
Timothy Jaeryang Baek d215e46315 refac: styling 2026-02-15 22:49:56 -06:00
Classic298 e10e7d056e chore: changelog (#21424)
* changelog: oauth session error handling

* changelog: task model selector validation fix

* changelog: skill content handling

* changelog: add Spanish and German translations

* changelog: move skill content to Added section

* changelog: tool call, validation fixes

* changelog: tool call message preservation links

* changelog: update per-model tool toggles entry with per-conversation chat toggle feature

* changelog: tool-server, startup, initialization

* changelog: remove empty Changed section

* changelog: fix duplicate Fixed section

* changelog: revert old entry, add new entry to 0.8.2

* changelog: move built-in tool toggles to Added section

* changelog: update date to 2026-02-16

* changelog: resource handle cleanup, file descriptors, leak fix

* changelog: image preview, file modal, modal enhancement
2026-02-15 19:33:20 -06:00
Timothy Jaeryang Baek 7a7d902238 refac 2026-02-15 19:32:22 -06:00
Varun Chawla 24179cde2f fix: preserve trailing slash in MCP server URLs (#21212)
Stop trimming trailing slashes from MCP server URLs on save. Some MCP
servers (e.g. Bitrix24) require a trailing slash; removing it triggers a
301 redirect that drops Authorization headers, resulting in 400 errors.

The trailing-slash trim is now skipped when the connection type is 'mcp',
while OpenAPI connections continue to have it stripped as before.

Fixes open-webui#21179
2026-02-15 19:09:41 -06:00
Timothy Jaeryang Baek 3ae4c618e1 refac 2026-02-15 19:07:53 -06:00
Timothy Jaeryang Baek 4a0d893995 refac 2026-02-15 19:03:08 -06:00
Timothy Jaeryang Baek b780d5c556 refac 2026-02-15 18:41:16 -06:00
Timothy Jaeryang BaekandDario Ruellan 911eecac85 fix: disabled mcp display issue
Co-Authored-By: Dario Ruellan <6965667+druellan@users.noreply.github.com>
2026-02-15 18:10:18 -06:00
Timothy Jaeryang Baek 319d3e8856 refac 2026-02-15 17:55:59 -06:00
Timothy Jaeryang Baek e1b3e7252c enh: preview image in file modal 2026-02-15 17:53:20 -06:00
Timothy Jaeryang BaekandSpootyMcSpoot f20cc6d7e6 refac
Co-Authored-By: SpootyMcSpoot <6732450+spootymcspoot@users.noreply.github.com>
2026-02-15 17:29:55 -06:00
Timothy Jaeryang Baek f1a1e64d2e refac: explicit toggle builtin tools 2026-02-15 17:20:49 -06:00
Classic298 58e923fe00 Update translation.json (#21441) 2026-02-15 16:43:50 -06:00
Timothy Jaeryang Baek f2aca781c8 refac: tool message handling 2026-02-15 16:14:47 -06:00
_00_ ce51c481b8 upd:i18n: Spanish Translation Update v.0.8.0 (#21427)
### Spanish Translation Update v.0.8.0

- Added new strings
2026-02-15 16:05:45 -06:00
Timothy Jaeryang Baek 9a2595f070 fix: task models issue 2026-02-14 22:26:03 -06:00
Timothy Jaeryang Baek 393c0071dc refac: manual skill invocation 2026-02-14 19:22:17 -06:00
Timothy Jaeryang Baek 7e224e4a53 refac 2026-02-13 18:26:03 -06:00
Tim Baek 883f1dda0f Merge pull request #21346 from open-webui/dev
0.8.1
2026-02-13 18:04:11 -06:00
Classic298 12bad452fa chore: Changelog updates (#21382)
* Add v0.8.1 release section

* changelog: knowledge menu layout fix

* changelog: knowledge tooltip z-index fix

* changelog: sync modal community sharing fix

* changelog: postgresql distinct ordering fix

* changelog: security fix public sharing bypass

* changelog: add issue ref to postgresql fix

* changelog: fix postgresql skills json compatibility

* changelog: apply new format style to 0.8.1 entries

* changelog: web search result count fix

* changelog: metadata, document, crash fix

* changelog: add channel user active status performance entry

* changelog: add model and prompt list optimization entry

* changelog: batch access control queries, channel status, model list optimization

* changelog: user list, performance, deferred loading

* Update CHANGELOG.md for 0.8.1

* Add emoji variation to Added section

* Remove empty Changed section and finalize 0.8.1 changelog

* changelog: arm, torch compatibility fix

* changelog: update database migration warning format

* changelog: ollama cloud, model naming fix

* Add SCIM externalId entry and database migration warning to 0.8.1

* Fix: move web search to Added, restore 0.8.0 headers

* Fix: SCIM above translations, 0.8.0 restored

* Remove 0.8.1 migration warning, keep 0.8.0 original

* changelog: direct model access control fix

* changelog: add commit link to direct model access control fix

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* changelog: sqlite, cascade delete, database fix

* changelog: responses, api, model-routing

* Add PR and issue links to SCIM externalId changelog entry

* Add commit link to Responses API entry, remove PR link from translation entry

* changelog: reasoning traces, performance, browser

* changelog: password, validation, regex

* Update CHANGELOG.md
2026-02-13 17:46:52 -06:00
Timothy Jaeryang Baek 5de60dc922 refac 2026-02-13 17:44:52 -06:00
Taylor Wilsdon a30b106ea3 fix issues/21399 (#21400) 2026-02-13 17:42:34 -06:00
Timothy Jaeryang Baek d33ad462aa refac 2026-02-13 17:38:57 -06:00
Timothy Jaeryang Baek 3b61562c82 refac 2026-02-13 17:26:54 -06:00
Timothy Jaeryang Baek e5d88be4f3 doc: changelog 2026-02-13 15:08:20 -06:00
Timothy Jaeryang Baek b36f8d9314 chore: format 2026-02-13 15:00:47 -06:00
Timothy Jaeryang Baek 626d236d13 chore: format 2026-02-13 15:00:39 -06:00
Timothy Jaeryang Baek 79ecbfc757 refac 2026-02-13 14:59:20 -06:00
Timothy Jaeryang Baek a9b8677cc0 refac 2026-02-13 14:59:05 -06:00
Classic298 0f3f68b0c4 enh (#21362) 2026-02-13 14:56:53 -06:00
Timothy Jaeryang BaekandJuan Calderon-Perez abc9b63093 refac
Co-Authored-By: Juan Calderon-Perez <835733+gaby@users.noreply.github.com>
2026-02-13 14:55:13 -06:00
Timothy Jaeryang Baek 64fa26bd28 refac 2026-02-13 14:48:10 -06:00
Timothy Jaeryang Baek 2487c84f1f refac: styling 2026-02-13 14:47:00 -06:00
Timothy Jaeryang Baek 163211a367 refac: styling 2026-02-13 14:43:06 -06:00
Timothy Jaeryang Baek f027a01ab2 fix: direct model access control 2026-02-13 14:39:01 -06:00
Classic298 370a677a38 fix: pin torch to prevent startup errors on ARM devices (#21385)
* fix: rpi

* Update requirements-min.txt

* Update requirements.txt

* Update pyproject.toml
2026-02-13 14:28:23 -06:00
Timothy Jaeryang Baek d1d1efe212 refac: scim 2026-02-13 14:27:11 -06:00
Timothy Jaeryang Baek b7549d2f6c refac: defer profile 2026-02-13 14:08:07 -06:00
Timothy Jaeryang Baek 589c4e64c1 refac 2026-02-13 13:56:29 -06:00
Timothy Jaeryang Baek 20de5a87da refac 2026-02-13 13:43:43 -06:00
Timothy Jaeryang Baek ca6b18ab5c refac: is_user_active 2026-02-13 13:40:59 -06:00
Classic298 97a3b1528d Update utils.py (#21105) 2026-02-13 13:37:12 -06:00
Classic298 d01b1d4880 enh: apply admin default to builtin web search (#21373) 2026-02-13 13:32:48 -06:00
Timothy Jaeryang Baek df6e38039f refac 2026-02-13 13:29:22 -06:00
Timothy Jaeryang Baek b4c3f54f96 fix: skills postgres issue 2026-02-13 11:24:08 -06:00
Classic298 73776d54b8 fix: enforce public sharing permission checks across all resource types (#21358)
The sharePublic prop in editor components (Knowledge, Tools, Skills,
Prompts, Models) incorrectly included an "|| edit" / "|| write_access"
condition, allowing users with write access to see and use the "Public"
sharing option regardless of their actual public sharing permission.
Additionally, all backend access/update endpoints only verified write
authorization but did not check the corresponding sharing.public_*
permission, allowing direct API calls to bypass frontend restrictions
entirely.
Frontend: removed the edit/write_access bypass from sharePublic in all
five editor components so visibility is gated solely by the user's
sharing.public_* permission or admin role.
Backend: added has_public_read_access_grant checks to the access/update
endpoints in knowledge.py, tools.py, prompts.py, skills.py, models.py,
and notes.py. Public grants are silently stripped when the user lacks
the corresponding permission.
Fixes #21356
2026-02-13 11:22:32 -06:00
Timothy Jaeryang BaekandEntropyYue 7bda6bf767 fix: PostgreSQL cannot use get_chat_ids_by_model_id
Co-Authored-By: EntropyYue <164553692+EntropyYue@users.noreply.github.com>
2026-02-13 11:20:26 -06:00
G30 ddcec9842f fix: ensure sync modal only triggers if community sharing is enabled (#21376) 2026-02-13 11:18:58 -06:00
G30 4d5b7b3014 fix: resolve knowledge tooltip z-index issue in model edit page (#21375) 2026-02-13 11:18:35 -06:00
G30 9886ebb97f fix: resolve knowledge collection indentation/truncation issue by correcting flex layout (#21374) 2026-02-13 11:18:14 -06:00
Timothy Jaeryang Baek 0b05b2fc7e refac 2026-02-13 00:44:01 -06:00
joaoback 49e7eade15 Merge pull request #21345 from joaoback/patch-25
i18n: Update translation.json (pt-BR)
2026-02-12 19:42:52 -06:00
Tim Baek 7a7a25766c Merge pull request #21314 from open-webui/dev
0.8.0
2026-02-12 17:42:25 -06:00
Timothy Jaeryang Baek 9fc1658085 refac 2026-02-12 17:42:15 -06:00
Timothy Jaeryang Baek 5297dceb2a chore: format 2026-02-12 17:36:19 -06:00
Timothy Jaeryang Baek 59afbd6f92 refac 2026-02-12 17:35:22 -06:00
Timothy Jaeryang Baek f7af3f010e refac 2026-02-12 17:27:38 -06:00
Timothy Jaeryang Baek bb40724d45 refac 2026-02-12 17:21:53 -06:00
Timothy Jaeryang Baek 0e64b31adb refac 2026-02-12 17:17:45 -06:00
Timothy Jaeryang Baek 850a864b02 refac 2026-02-12 16:54:32 -06:00
Timothy Jaeryang Baek 9468d92553 refac 2026-02-12 16:44:08 -06:00
Timothy Jaeryang Baek c3dc5d5984 refac 2026-02-12 16:41:57 -06:00
Timothy Jaeryang Baek 5291b3dca2 refac 2026-02-12 16:36:00 -06:00
Classic298andClassic298 87d0c112fa Update CHANGELOG.md (#20594)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* docs(changelog): update CHANGELOG.md for file upload settings fix

* docs: add RTL markdown table display fix to CHANGELOG

* changelog: add GPU usage fix for online status indicator

- Fixed high GPU usage (35-40% to near-zero) caused by animate-ping CSS
  animation on the user online status indicator

Refs: #21062, 938d1b0

* docs(changelog): add ANSI color code fix for code execution

- Fixed code execution reliability when LLMs include ANSI terminal
  color codes in their output
- Prevents random syntax errors during code execution
- Related to issue #21091

* fix: restore tool call expand/collapse functionality

A refactor moved tool call display logic into a separate component but inadvertently disabled the collapsible behavior because tool calls store content in attributes rather than body text.

Fixes #21205

* changelog: fix malformed Authorization header crash in Fixed section

- Add entry for malformed Authorization header fix (issue #20938)

The APIKeyRestrictionMiddleware now validates Authorization headers before
processing them, preventing ValueError and HTTP 500 errors.

* Add notification toast close button entry to CHANGELOG

* Update changelog date to 2026-02-07

* fix: respect ENABLE_OLLAMA_API and ENABLE_OPENAI_API flags when url_idx is provided

* Update CHANGELOG.md

* Add Latvian (lv-LV) language translation

* changelog: add relative time indicators for chat updates

* Update CHANGELOG.md

* changelog: add active chat tasks sidebar feature

* changelog: Update date to 2026-02-10 and fix tool call result decoding issue (#20600)

* changelog: Consolidate Open Responses protocol commits

* changelog: Add Irish translations

* changelog: icons, dark-mode, display

* changelog: Fix dark mode icon display issue (#21272)

* changelog: duplicate, icons, dark-mode

* changelog: access-control-ui, sharing-users, api-groups-users

* changelog: reorder access control entries for visibility

* changelog: move api entry down

* changelog: batch-file-ops, knowledge-base, performance

* changelog: add Gboard Android clipboard fix entry

* changelog: opensearch, knowledge-base, typefix

* changelog: add Gboard Android fix entry

* Update CHANGELOG.md

* changelog: expand user sharing entry

* changelog: remove emphasis word from user sharing

* changelog: reformulate user sharing entry per guidelines

* changelog: notifications, new-chat-fallback, device-scoped

* changelog: fix embeddings endpoint batch input for Ollama

* changelog: replace backticks with single quotes per style guidelines

* changelog: ssl-embeddings, aiohttp-session-ssl

* changelog: add Ollama auto-port fallback entry

* changelog: add independent access control endpoints and permission fixes

* changelog: consolidate access control entries and improve permission fix description

* changelog: model selector, virtual scrolling, performance

* changelog: update date to February 11, 2026

* changelog: pagination, model settings, admin

* changelog: add Skill standard feature entry

* changelog: add French to translations list

* changelog: action-html, iframe, rich-content

* changelog: tool call, html entities, multi-turn

* changelog: redis model cache, performance

* changelog: inline-images, context-window, fix-20916

* changelog: fix inline images context window exhaustion

* changelog: audio, timeout, api

* changelog: cache optimization, performance, ttft

* changelog: ldap, authentication, freeze

* changelog: async pipelines, event loop

* changelog: streaming, connection, cleanup

* changelog: add commits for Action Rich UI refactor and group performance

* changelog: model access control, batch queries, database optimization

* changelog: trusted-header, authentication, user-registration

* changelog: add pinned read-only models entry

* changelog: update date to February 12, 2026

* Update CHANGELOG.md

* Update CHANGELOG.md

* changelog: add issue #17058 to streaming response cleanup entry

* changelog: reorder Fixed section by importance priority

* Update CHANGELOG.md

* changelog: add warning about chat message table migration duration

* changelog: openai-api-router, model-cache-lookup, bugfix

* changelog: ollama-model-name-fix, provider-compatibility

* changelog: fix Ollama-compatible provider model name issue #21331

* changelog: OpenAI API endpoint detection fix

* changelog: model list and import performance optimization

* changelog: external tool event emitter message id support #21214

* changelog: fix large document upload worker timeout #21158

* changelog: fix RedisCluster task stopping

* changelog: add discussion link to #21158

* changelog: add commit c653e4ec to #21158 entry

* changelog: functions, chat_id, internal_tasks

* changelog: channel permissions security fix

---------

Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com@>
2026-02-12 16:15:05 -06:00
Timothy Jaeryang Baek 2a11175f22 chore: format 2026-02-12 16:13:48 -06:00
Timothy Jaeryang Baek 3238d94a0e refac 2026-02-12 15:57:27 -06:00
Timothy Jaeryang Baek 8919d8a82a refac 2026-02-12 15:52:50 -06:00
Classic298andTim Baek ea4ef28da5 init (#20883)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-02-12 15:50:13 -06:00
Timothy Jaeryang Baek 2ffd8d9277 refac 2026-02-12 15:40:13 -06:00
Classic298 e8499ccdd1 fix: pass chat_id to internal task calls for consistent function context (#20585)
Ensure chat_id is reliably passed to function pipelines/manifolds during internal task invocations (web search query generation, RAG query generation, image prompt generation).

This allows stateful functions to maintain per-chat state without fragmentation, as they will now receive a consistent chat_id for all chat-scoped invocations including internal tasks.

Backend changes:
- Pass chat_id in generate_queries call for web search
- Pass chat_id in generate_queries call for RAG/retrieval
- Pass chat_id in generate_image_prompt call

Frontend changes:
- Add optional chat_id parameter to generateQueries API function
- Add optional chat_id parameter to generateAutoCompletion API function

Fixes #20563
2026-02-12 15:36:42 -06:00
Timothy Jaeryang Baek 0dcbd05e24 refac 2026-02-12 15:29:34 -06:00
Timothy Jaeryang Baek c653e4ec54 refac 2026-02-12 15:25:24 -06:00
Classic298andClaude 8cf32ae2a7 fix: prevent worker death during document upload by using run_coroutine_threadsafe (#21158)
* fix: prevent worker death during document upload by using run_coroutine_threadsafe

Replace asyncio.run() with asyncio.run_coroutine_threadsafe() in
save_docs_to_vector_db() to prevent uvicorn worker health check failures.

The issue: asyncio.run() creates a new event loop and blocks the thread
completely, preventing the worker from responding to health checks during
long-running embedding operations (>5 seconds default timeout).

The fix: Schedule the async embedding work on the main event loop using
run_coroutine_threadsafe(). This keeps the main loop responsive to health
check pings while the sync caller waits for the result.

Changes:
- main.py: Store main event loop reference in app.state.main_loop at startup
- retrieval.py: Use run_coroutine_threadsafe() instead of asyncio.run()

https://claude.ai/code/session_01UQSYvSTkXb57sFb7M85Kcw

* add env var

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-12 15:22:57 -06:00
Classic298 c6af296b60 add message id (#21214) 2026-02-12 15:04:27 -06:00
Timothy Jaeryang Baek 531ac70ce5 refac 2026-02-12 12:07:45 -06:00
Timothy Jaeryang Baek 423d8b1817 refac 2026-02-12 11:04:34 -06:00
EntropyYue 633505460a i18n: improve zh-CN translation (#21319) 2026-02-12 11:02:41 -06:00
Aleix Dorca c8bf390680 i18n: Update catalan translation (#21320)
* Update catalan translation.json type

* Update catalan translation.json
2026-02-12 11:02:25 -06:00
Classic298 da46c1bbd2 Update translation.json (#21324) 2026-02-12 11:02:10 -06:00
Timothy Jaeryang Baek 05ae44b98d refac 2026-02-12 11:01:46 -06:00
Timothy Jaeryang Baek a40808579f refac 2026-02-12 10:59:41 -06:00
Timothy Jaeryang Baek 4d024c91d6 refac 2026-02-11 18:33:35 -06:00
Timothy Jaeryang Baek ccb71a7322 refac 2026-02-11 18:32:14 -06:00
Classic298andMichael efe5416f83 fix: reduce TTFT by caching model lookups in chat completion (#20886)
fix: reduce TTFT by caching model lookups in chat completion

Skip expensive get_all_models() calls when models are already cached
in app.state. This significantly reduces Time To First Token (TTFT)
for chat completions and embeddings requests.

Previously, every request called get_all_models() which fetches model
lists from all configured backends. Now we check the cache first and
only call get_all_models() on cache miss.

Affected endpoints:
- openai: generate_chat_completion, embeddings
- ollama: embed, embeddings

Fixes #20069

Co-authored-by: Michael <42099345+mickeytheseal@users.noreply.github.com>
2026-02-11 18:29:10 -06:00
Timothy Jaeryang Baek a4281f6a7f refac: ldap 2026-02-11 18:25:37 -06:00
Timothy Jaeryang Baek 2372b70031 refac: async pipelines requests 2026-02-11 18:24:30 -06:00
Classic298andClaude d02e826c9d Fix idle in transaction leaks in Open WebUI (#20868)
* fix: add ScopedSession.remove() to prevent idle transaction leaks

The HTTP middleware was calling ScopedSession.commit() but not
ScopedSession.remove(), causing database connections to remain
"checked out" from the pool indefinitely. This resulted in
"idle in transaction" connections in PostgreSQL that could persist
for 30-50+ minutes.

With SQLAlchemy's scoped_session:
- commit() commits but keeps the session active
- remove() is required to return the connection to the pool

This fix adds the missing remove() call, ensuring connections are
properly returned after each HTTP request.

Also includes IDLE_TRANSACTION_ANALYSIS.md documenting the full
root cause analysis and additional recommendations.

* Delete IDLE_TRANSACTION_ANALYSIS.md

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 18:20:03 -06:00
Timothy Jaeryang BaekandClassic298 dddac2b0ca refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-02-11 18:19:01 -06:00
Timothy Jaeryang BaekandClassic298 0da57149ae refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-02-11 18:13:30 -06:00
Classic298 0bebb260bf fix: decode HTML entities in tool call results for multi-turn conversations (#20755) 2026-02-11 18:11:41 -06:00
Thomas Rehn 390d7663b0 fix: don't pollute model text context with encoded images (#20916) 2026-02-11 18:10:22 -06:00
Thomas Rehn ce3a615442 perf: cache OpenAI config reads to avoid redundant Redis lookups in /api/models (#21306)
Each access to request.app.state.config.<KEY> triggers a synchronous
Redis GET. In get_all_models_responses() and get_merged_models(), the
config keys OPENAI_API_BASE_URLS, OPENAI_API_KEYS, and
OPENAI_API_CONFIGS were read on every loop iteration — resulting
in some cases in 200-300 Redis round-trips for OPENAI_API_BASE_URLS alone.

Read each config value once into a local variable at the start of the
function and reuse it throughout.
2026-02-11 17:59:50 -06:00
Timothy Jaeryang BaekandJannik S. 4e0cb88583 refac: audio timeout
Co-Authored-By: Jannik S. <jannik@streidl.dev>
2026-02-11 17:56:49 -06:00
Timothy Jaeryang Baek 9b30e8f689 refac 2026-02-11 17:53:01 -06:00
Timothy Jaeryang Baek 97331bf11d refac 2026-02-11 17:26:25 -06:00
Timothy Jaeryang Baek 96c07f44a8 refac 2026-02-11 16:45:47 -06:00
Timothy Jaeryang Baek 60ada21c15 refac 2026-02-11 16:40:40 -06:00
Classic298andClaude ab20745ee5 Enable Rich UI embed support for action functions (#21294)
Action functions can now return HTMLResponse objects or (html, headers)
tuples with Content-Disposition: inline to render rich UI iframes in
chat, matching the existing tool behavior.

https://claude.ai/code/session_01KCZKQXj1uqgPjqjMd4U2NF

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 16:27:00 -06:00
Timothy Jaeryang Baek f376d4f378 chore: format 2026-02-11 16:24:11 -06:00
Florian 89fddcc741 update french translation.json (#21295) 2026-02-11 16:22:55 -06:00
Timothy Jaeryang Baek 773787c74c refac 2026-02-11 16:16:41 -06:00
Tim Baek 5c1c9a4dcb Merge pull request #21312 from open-webui/skills
feat: skills
2026-02-11 16:01:08 -06:00
Timothy Jaeryang Baek 9b925a115a refac 2026-02-11 16:00:19 -06:00
Timothy Jaeryang Baek e5035ea31e refac 2026-02-11 15:55:23 -06:00
Timothy Jaeryang Baek c8cbdc8f7f refac 2026-02-11 15:24:12 -06:00
Timothy Jaeryang Baek 64c37ab968 refac 2026-02-11 15:12:37 -06:00
Timothy Jaeryang Baek f7c5965a70 feat: $ skills mention 2026-02-11 15:08:59 -06:00
Timothy Jaeryang Baek 46aa54b7dc refac 2026-02-11 14:35:45 -06:00
Timothy Jaeryang Baek 27944cf7ca refac 2026-02-11 14:26:56 -06:00
Timothy Jaeryang Baek 1973115678 feat: skills frontend 2026-02-11 14:22:26 -06:00
Timothy Jaeryang Baek a38ad8fc42 refac 2026-02-11 14:09:55 -06:00
Timothy Jaeryang Baek c2207887b3 feat: skills backend 2026-02-11 14:00:34 -06:00
Timothy Jaeryang Baek 3fabc085cc feat: skills migration 2026-02-11 03:58:28 -06:00
Timothy Jaeryang Baek 2f584c9f88 refac 2026-02-11 02:31:17 -06:00
Timothy Jaeryang Baek ba18d6250a refac 2026-02-11 02:26:01 -06:00
Timothy Jaeryang Baek 4331029926 refac 2026-02-11 02:20:07 -06:00
Timothy Jaeryang Baek 3e56261c5e refac 2026-02-11 02:06:43 -06:00
Timothy Jaeryang Baek 30f72672fa refac 2026-02-10 15:57:08 -06:00
Timothy Jaeryang Baek 4aedfdc547 refac 2026-02-10 15:47:21 -06:00
Timothy Jaeryang Baek e3a8257690 refac 2026-02-10 15:41:11 -06:00
Timothy Jaeryang Baek a73cdf4288 refac: dmr fallback for ollama 2026-02-10 15:38:21 -06:00
Timothy Jaeryang Baek c259c87806 refac 2026-02-10 15:30:16 -06:00
Timothy Jaeryang Baek 0044902c08 refac 2026-02-10 15:15:17 -06:00
Timothy Jaeryang Baek cd31b8301b refac 2026-02-10 12:44:31 -06:00
Timothy Jaeryang Baek 8fd5c06e5b refac 2026-02-10 12:23:08 -06:00
Classic298andClaude 97afe3bc58 enh: skip chat notifications on other devices if temporary chat (#21292)
* Merge pull request #20581 from Classic298/fix/db-pool-memory-update (#150)

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

* Update +layout.svelte

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-10 13:21:57 -05:00
Timothy Jaeryang Baek 3567054325 refac 2026-02-09 18:22:28 -06:00
Classic298 f236192fe1 fix: resolve N+1 query in knowledge batch file add (#21006) 2026-02-09 16:17:30 -06:00
Varun Chawla 9b1fd86aa7 fix: use keyword argument for IndicesClient.refresh() for opensearch-py 3.x (#21248)
In opensearch-py >= 3.0.0, IndicesClient.refresh() no longer accepts the
index name as a positional argument. This causes a TypeError when
uploading documents to knowledge bases with OpenSearch backend.

Changes positional arguments to keyword arguments (index=...) in all
three refresh() calls in the OpenSearch vector DB client.

Fixes #20649
2026-02-09 16:16:44 -06:00
Classic298 55169e69c0 chore: Dep bump (#21261)
* Update pyproject.toml

* Update requirements-min.txt

* Update package versions in requirements.txt

Updated various package versions in requirements.txt to latest releases.
2026-02-09 16:15:39 -06:00
Algorithm5838 e3e4e1d9d3 fix: handle Gboard clipboard strip multi-line paste via beforeinput (#21265)
Gboard's clipboard suggestion strip sends multi-line pastes as
'insertText' in beforeinput rather than a standard paste event,
causing ProseMirror to drop the text content and insert only a
newline. Add a beforeinput handler that intercepts multi-line
insertText events on Android and manually inserts with hard breaks
to preserve the formatting.
2026-02-09 16:15:08 -06:00
Timothy Jaeryang Baek c2f5cb542e refac 2026-02-09 14:03:35 -06:00
Timothy Jaeryang Baek 1034b74abd refac 2026-02-09 13:48:48 -06:00
Timothy Jaeryang Baek 0a44d80252 refac 2026-02-09 13:42:27 -06:00
Tim Baek 48a0abb40f Merge pull request #21277 from open-webui/acl
refac: acl
2026-02-09 13:34:36 -06:00
Timothy Jaeryang Baek 68c77295bd refac 2026-02-09 13:31:59 -06:00
Timothy Jaeryang Baek 3c7f9aa6a4 refac 2026-02-09 13:28:21 -06:00
Timothy Jaeryang Baek f7406ff576 refac 2026-02-09 13:28:14 -06:00
65904b867e i18n: Updated Irish translation (#21253)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-02-09 14:25:28 -05:00
Tim Baek e2d09ac361 refac 2026-02-09 09:06:48 +04:00
Timothy Jaeryang Baek 3ae44d11a5 refac 2026-02-08 22:37:13 -06:00
Tim Baek aa8c2959ca refac 2026-02-09 08:07:33 +04:00
Timothy Jaeryang Baek b147616080 refac 2026-02-08 21:32:12 -06:00
Timothy Jaeryang Baek 9747b07ca5 refac 2026-02-08 21:24:38 -06:00
Timothy Jaeryang Baek 0f78451c2b refac 2026-02-08 21:24:20 -06:00
Timothy Jaeryang Baek 42763cbbd8 enh: access grants table 2026-02-08 21:21:25 -06:00
Timothy Jaeryang Baek d193c143a5 refac 2026-02-08 21:18:38 -06:00
Tim Baek 26460917c4 refac 2026-02-08 07:20:28 +04:00
Tim Baek fd97ae9bd7 refac 2026-02-08 06:48:41 +04:00
Tim Baek c3fa0f30fe refac 2026-02-08 06:45:01 +04:00
Tim Baek 4852227158 refac 2026-02-08 06:22:56 +04:00
Timothy Jaeryang Baek 9be85b6d3c refac: access control migration 2026-02-07 19:07:49 -06:00
Tim Baek 59b98ab730 refac 2026-02-08 03:42:57 +04:00
Tim Baek 690686f3c7 refac 2026-02-08 03:40:54 +04:00
Tim Baek 691a04f0dd refac 2026-02-08 03:39:29 +04:00
4fa6123c04 i18n: Add Latvian translation (#21234)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Edgaras0x4E <edgaras217t@gmail.com >
2026-02-08 03:33:34 +04:00
Classic298 494cf8b3ef fix (#21226) 2026-02-08 03:19:26 +04:00
Tim Baek 73bb600034 refac 2026-02-06 23:35:21 +04:00
Tim Baek 9cf4d34832 refac 2026-02-06 23:21:09 +04:00
Tim Baek 284b97bd84 refac 2026-02-06 22:46:56 +04:00
Tim Baek 7e79f8d1c6 refac 2026-02-06 22:39:20 +04:00
Tim Baek 258454276e fix: files settings save issue 2026-02-06 22:33:49 +04:00
Tim Baek 938d1b0743 refac 2026-02-06 22:29:16 +04:00
Tim Baek b1737040a7 refac 2026-02-06 22:25:18 +04:00
Timothy Jaeryang Baek 26286625f4 refac 2026-02-05 23:40:58 -06:00
Tim Baek a214ec40ea fix 2026-02-06 03:34:21 +04:00
Tim Baek 2c37daef86 refac 2026-02-06 03:23:37 +04:00
Tim Baek 8e79b3d0bc refac 2026-02-06 03:03:34 +04:00
Tim Baek 6c0f886cdf refac 2026-02-06 02:48:35 +04:00
Shirasawa 225036863f I18n: improve Chinese translation (#21181)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-02-06 02:28:37 +04:00
G30 cac5dd12e9 fix: handle null data in model_response_handler (#21112)
Fix `AttributeError` in `model_response_handler` when processing channel messages with `null` data field. The function iterates over thread messages to build conversation history, but some messages may have `data=None` causing a crash when accessing `thread_message.data.get()`. Added null check using `(thread_message.data or {}).get("files", [])` to safely handle messages without data.
2026-02-05 15:15:34 -05:00
Classic298 f751c0b46c Update builtin.py (#21115) 2026-02-05 15:14:58 -05:00
Classic298 9ed8f50d40 fix: bundle NLTK punkt_tab in Docker image for airgapped environments (#21165)
Pre-download NLTK punkt_tab during Docker build instead of at runtime.
This fixes document extraction failures in offline/airgapped environments
where the container cannot download the tokenizer data after restarts.
Fixes #21150
2026-02-05 15:11:00 -05:00
Tim Baek d3f2cf7474 refac 2026-02-05 10:24:12 +04:00
Tim Baek 62750b8980 feat: queue messages 2026-02-05 10:06:37 +04:00
Timothy Jaeryang Baek e62649f940 enh: analytics 2026-02-05 00:00:49 -06:00
Timothy Jaeryang Baek 0e60c757ce refac 2026-02-04 23:47:16 -06:00
Timothy Jaeryang Baek 68a1e87b66 enh: analytics model modal 2026-02-04 23:42:46 -06:00
Timothy Jaeryang Baek e8a36f033b refac 2026-02-04 23:05:08 -06:00
Timothy Jaeryang Baek 2cf2565e80 refac 2026-02-04 22:57:46 -06:00
Timothy Jaeryang Baek 5669d1062c refac 2026-02-04 21:54:45 -06:00
Timothy Jaeryang Baek 3ace75820e refac 2026-02-04 21:46:20 -06:00
Timothy Jaeryang Baek 020cb0d4bf refac 2026-02-04 21:09:05 -06:00
Timothy Jaeryang Baek 8b75d34a8a refac 2026-02-04 21:07:30 -06:00
Timothy Jaeryang Baek 6320a9aaa9 refac 2026-02-04 20:47:21 -06:00
Timothy Jaeryang Baek 6cd35b185d refac 2026-02-04 00:13:05 -06:00
Timothy Jaeryang Baek a1ea854b38 refac 2026-02-04 00:08:46 -06:00
Timothy Jaeryang Baek 405dc26cc6 refac 2026-02-04 00:04:09 -06:00
Timothy Jaeryang Baek fe681abd33 refac: tags 2026-02-03 23:58:11 -06:00
Timothy Jaeryang Baek 43c68468f7 refac 2026-02-03 23:38:03 -06:00
Timothy Jaeryang Baek ecf3fa2feb refac 2026-02-03 23:36:15 -06:00
EntropyYue 4aacaeb9b8 fix: Shortcut i18n (#21138) 2026-02-03 16:52:38 -06:00
Timothy Jaeryang Baek afc56b9746 refac 2026-02-03 16:34:29 -06:00
Timothy Jaeryang Baek 0a61666197 refac 2026-02-03 16:33:12 -06:00
Thomas Rehn a9e0462e57 feat: add keyboard shortcut for opening the model selector (#21130) 2026-02-03 14:09:02 -06:00
Tim Baek a2b9986a75 Merge pull request #21129 from alpha-pet/fix-redis-cluster-otel
fix: OTEL instrumentation for redis in cluster mode
2026-02-03 14:07:51 -06:00
Thomas Rehn 654172d757 fix: redis clustermode instrumentation 2026-02-03 15:25:37 +01:00
Timothy Jaeryang Baek 527d48efa9 refac 2026-02-02 10:04:13 -06:00
Tim Baek cda08aaed4 Merge pull request #21106 from open-webui/chat-message-rebased
feat: analytics
2026-02-02 09:34:18 -06:00
Tim Baek cfd30581d5 Merge branch 'dev' into chat-message-rebased 2026-02-02 09:33:41 -06:00
Timothy Jaeryang Baek 3c0313f41b refac 2026-02-02 09:31:52 -06:00
Timothy Jaeryang Baek d938eb0e76 refac 2026-02-02 09:23:46 -06:00
Classic298 60f2f8c1c4 Update pyproject.toml (#21087) 2026-02-02 09:21:39 -06:00
Timothy Jaeryang Baek b0c5f7b668 refac 2026-02-02 09:02:34 -06:00
Timothy Jaeryang Baek 767343dc5b refac 2026-02-02 08:57:26 -06:00
Timothy Jaeryang Baek c22bb4f853 refac 2026-02-02 08:55:13 -06:00
Timothy Jaeryang Baek 117c091b95 refac 2026-02-01 20:07:11 -06:00
Timothy Jaeryang Baek 6719558150 refac 2026-02-01 20:06:38 -06:00
Timothy Jaeryang Baek 6ffce4bccd refac 2026-02-01 20:00:21 -06:00
Timothy Jaeryang Baek ea9c58ea80 feat: experimental responses api support 2026-02-01 19:39:28 -06:00
Tim Baek b2c2f1bd49 refac 2026-02-01 10:24:04 +04:00
Tim Baek 679e56c494 feat: token analytics 2026-02-01 10:19:59 +04:00
Tim Baek 3da4323ef3 refac 2026-02-01 10:11:13 +04:00
Tim Baek 75e5a485d2 refac 2026-02-01 10:06:52 +04:00
Tim Baek 7bb3a827bb refac 2026-02-01 08:10:25 +04:00
Tim Baek 96f106319e refac 2026-02-01 07:59:02 +04:00
Tim Baek a4ad34841b feat: analytics frontend dashboard
- Add Dashboard with summary stats, model/user tables
- Add ChartLine component with multi-model support
- Interactive hover tooltips and model breakdown
- Hourly granularity for 24h, daily for 7d+
2026-02-01 07:04:23 +04:00
Tim Baek 599cd2eeeb feat: analytics backend API with chat_message table
- Add chat_message table for message-level analytics with usage JSON field
- Add migration to backfill from existing chats
- Add /analytics endpoints: summary, models, users, daily
- Support hourly/daily granularity for time-series data
- Fill missing days/hours in date range
2026-02-01 07:04:13 +04:00
Tim Baek ee5fd1246c refac 2026-02-01 03:26:59 +04:00
Timothy Jaeryang Baek 1441d0d735 refac 2026-01-31 17:03:29 -06:00
Timothy Jaeryang Baek e5dbfc420d refac 2026-01-31 17:03:24 -06:00
Classic298 643c661a6f chore: Updates minor/patch versions for 21 Python backend dependencies (#21059) 2026-01-31 16:51:08 -06:00
Timothy Jaeryang Baek ab5dfbda54 refac 2026-01-30 11:12:54 -05:00
Timothy Jaeryang Baek 94302de49b feat: images playground 2026-01-30 10:54:04 -05:00
Timothy Jaeryang Baek 45d7486485 refac 2026-01-30 10:43:40 -05:00
Classic298 ee27fd8de1 perf: replace double lookup with single JOIN in authenticate_user_by_email (#21010)
* perf: replace double lookup with single JOIN in authenticate_user_by_email

* Update auths.py
2026-01-30 10:22:07 -05:00
Classic298 89f154630f fix 404 (#21047) 2026-01-30 10:15:40 -05:00
Timothy Jaeryang Baek a6ed0ef9f4 refac 2026-01-30 10:13:14 -05:00
Classic298 aac98120c8 perf: batch fetch filter functions to eliminate N+1 queries (#21018) 2026-01-30 00:50:04 +04:00
Timothy Jaeryang Baek 44e36e5b0d refac 2026-01-30 00:46:56 +04:00
Timothy Jaeryang BaekandHsienz f9ab66f51a refac
Co-Authored-By: Hsienz <55347238+hsienz@users.noreply.github.com>
2026-01-30 00:46:42 +04:00
Classic298 ce8ed5b5ec feat(files): add shift+click quick delete to File Manager (#21044)
* feat(files): add shift+click quick delete to File Manager

Add shift+click functionality to FilesModal for rapid file deletion without confirmation dialogs.
Changes:
- Track Shift key state via keyboard event listeners
- When Shift is held, delete button bypasses confirmation and deletes immediately
- Visual feedback: delete icon turns red when Shift is held
- Optimized delete to remove file from local array instead of re-fetching entire list, enabling rapid successive deletions without UI flicker
This matches the quick delete pattern used in other workspace components like Tools, Prompts, and Models.

* Update FilesModal.svelte
2026-01-30 00:18:39 +04:00
Classic298 baef422a28 perf: eliminate redundant query after memory update (#21013)
## Summary
Eliminates redundant database query in update_memory_by_id_and_user_id. Previously, after modifying the memory object, it called get_memory_by_id which opened a new session and queried again.
## Changes
models/memories.py update_memory_by_id_and_user_id:
- Replace self.get_memory_by_id(id) with db.refresh(memory)
- Return the same memory object that was already modified
## Performance Impact
Before: 2 queries (get + get_memory_by_id)
After: 1 query + refresh on same session
2026-01-29 21:45:09 +04:00
Classic298 68e257849d perf: optimize database queries in functions, feedbacks, and groups (#21019) 2026-01-29 21:44:50 +04:00
Classic298 e686554392 fix: resolve N+1 query in SCIM group_to_scim user lookup (#21005) 2026-01-29 21:43:33 +04:00
Timothy Jaeryang Baek 96a9696383 enh: admin models 2026-01-29 21:33:56 +04:00
ShirasawaandTim Baek fa84ff5e12 I18n: improve Chinese translation (#20902)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-01-29 20:58:26 +04:00
7. Sun 66359d5815 fix: clean up all event listeners in +layout.svelte onMount (#20913)
Extract anonymous touch event handlers into named functions and add
proper cleanup for all event listeners (message, touchstart, touchmove,
touchend, visibilitychange) in the onMount return function.

Previously only the resize listener was being cleaned up, causing
memory leaks during navigation and hot-reloads.
2026-01-29 20:50:25 +04:00
Classic298 c111fa0837 fix: add lang="ts" to Svelte script tags using TypeScript syntax (#21001)
fix: add lang="ts" to Svelte script tags using

TypeScript syntax
Two Svelte components were using TypeScript type annotations (ReturnType<typeof setTimeout>)
but their script tags were missing the lang="ts" attribute. This caused Vite/Svelte to parse
them as plain JavaScript, resulting in parse errors.

Fixed files:
- src/lib/components/admin/Users/UserList.svelte
- src/lib/components/channel/ChannelInfoModal/UserList.svelte
2026-01-29 20:49:25 +04:00
Timothy Jaeryang Baek 6e182940e2 refac 2026-01-29 20:30:22 +04:00
Timothy Jaeryang Baek bc90463ea6 refac 2026-01-29 20:15:23 +04:00
Timothy Jaeryang Baek 93ed4ae2cd enh: files data controls 2026-01-29 19:50:06 +04:00
Timothy Jaeryang Baek a10ac774ab enh: manage shared chats 2026-01-29 18:51:02 +04:00
Timothy Jaeryang Baek 26a5d8f75d refac 2026-01-29 18:05:07 +04:00
Classic298 5749f78262 fix: reduce triple query to single fetch in model toggle (#21009) 2026-01-29 17:24:09 +04:00
Classic298 1eaae9d934 perf: eliminate redundant SELECT after UPDATE in users.py (#21011) 2026-01-29 17:22:28 +04:00
Cevat Batuhan Tolon 567b0776cd REDIS_RECONNECT_DELAY as positive float for handle retry delay on timeout/connection errors (#21021) 2026-01-29 17:13:03 +04:00
72f330133a add slovak translations (#21032)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Tomáš Lokša <loksa@kros.sk>
2026-01-29 17:11:35 +04:00
Timothy Jaeryang Baek 665f95eda3 refac 2026-01-28 01:18:39 +04:00
Tim Baek 8e2b0b6fd2 enh: playground export 2026-01-27 16:17:17 -05:00
Timothy Jaeryang Baek ce50d9bac4 refac 2026-01-28 01:14:22 +04:00
Timothy Jaeryang Baek 934bebd8cd refac 2026-01-28 01:02:10 +04:00
Timothy Jaeryang Baek 171940869b refac 2026-01-28 00:49:12 +04:00
7. Sun 33020d826f perf: parallelize image loading in image_edits endpoint (#20911)
Use asyncio.gather() to load multiple images concurrently instead of
sequentially, significantly reducing latency for multi-image edit
operations.
2026-01-28 00:35:25 +04:00
Classic298 2c12278444 perf: Debounce various Database Endpoints for less Database Queries and better Backend performance (#20982)
* Update KnowledgeSelector.svelte

* Update KnowledgeSelector.svelte

* Update Users.svelte

* Update MemberSelector.svelte

* Update MemberSelector.svelte

* Update Knowledge.svelte

* Update Knowledge.svelte

* Update Notes.svelte

* Update Knowledge.svelte

* Update Prompts.svelte

* Update Tools.svelte

* Update Tools.svelte

* Update Prompts.svelte

* Update Prompts.svelte

* Update Prompts.svelte

* Update Functions.svelte

* Update UserList.svelte

* Update Functions.svelte

* Update Prompts.svelte

* Update UserList.svelte
2026-01-28 00:33:23 +04:00
Timothy Jaeryang Baek 57a2024c58 refac 2026-01-27 23:55:49 +04:00
Classic298 d67fe02263 Update translation.json (#20959) 2026-01-27 23:40:17 +04:00
Timothy Jaeryang Baek 57ec2aa088 refac 2026-01-27 23:34:23 +04:00
Timothy Jaeryang Baek fa859de460 refac 2026-01-27 23:21:31 +04:00
Timothy Jaeryang Baek 36766f157d refac: prompts pagination 2026-01-27 23:01:56 +04:00
Timothy Jaeryang Baek 683438b418 refac 2026-01-27 21:37:20 +04:00
Timothy Jaeryang Baek 4a55167759 refac 2026-01-26 18:04:58 +04:00
c5c4aef7b1 Yandex web search (#20922)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-26 07:31:44 -05:00
Peter Dave Hello 533c7b27eb i18n: Improve zh-TW Traditional Chinese translation (#20920) 2026-01-26 07:30:56 -05:00
Tim Baek 82af218790 Merge pull request #20945 from open-webui/prompt_versioning
enh: prompts
2026-01-26 16:27:28 +04:00
Classic298 b272ca5e88 fix: remove invalid expunge call on Pydantic FileModel (#20931)
fix: remove invalid expunge call on Pydantic FileModel
Files.get_file_by_id() returns a Pydantic FileModel, not an SQLAlchemy
ORM object. Calling db.expunge() on a Pydantic model fails with
UnmappedInstanceError since it lacks _sa_instance_state.
The expunge was also unnecessary because subsequent DB updates already
use fresh sessions via get_db() context manager.
Fixes #20925
2026-01-26 07:24:53 -05:00
Timothy Jaeryang Baek 10ba2accf7 refac 2026-01-26 16:18:49 +04:00
Timothy Jaeryang Baek 4c8d4e6dbd enh: prompt tags 2026-01-26 16:11:10 +04:00
Tim Baek 6359628bc3 refac 2026-01-26 07:01:05 -05:00
Classic298 25fd342261 Update retrieval.py (#20930) 2026-01-26 15:29:15 +04:00
Timothy Jaeryang Baek f199c486a2 refac 2026-01-24 04:29:56 +04:00
Timothy Jaeryang Baek 1f205a8441 refac 2026-01-24 04:19:51 +04:00
Timothy Jaeryang Baek b1d5b3b28e refac 2026-01-24 04:17:11 +04:00
Timothy Jaeryang Baek 32810b4152 refac 2026-01-24 04:13:19 +04:00
Timothy Jaeryang Baek b7e9992d78 refac 2026-01-24 03:25:29 +04:00
Timothy Jaeryang Baek 6c76983999 refac 2026-01-24 03:21:47 +04:00
Timothy Jaeryang Baek 8bf46dcc5d refac 2026-01-24 03:16:34 +04:00
Timothy Jaeryang Baek 5510fa178e refac 2026-01-24 03:10:29 +04:00
Timothy Jaeryang Baek 5ad593e465 refac: prompt endpoints 2026-01-24 03:08:48 +04:00
Timothy Jaeryang Baek dff0141160 refac 2026-01-24 02:58:23 +04:00
Timothy Jaeryang Baek 44da9c6523 refac 2026-01-24 02:57:08 +04:00
Timothy Jaeryang Baek 6ab7d54982 refac 2026-01-24 02:51:44 +04:00
Timothy Jaeryang Baek 0c79a566ac feat: prompt history frontend 2026-01-24 02:40:17 +04:00
Timothy Jaeryang Baek 34773e795b wip: prompt history backend integration 2026-01-24 02:39:48 +04:00
Timothy Jaeryang Baek 66daa15722 wip: prompt history models 2026-01-24 02:39:29 +04:00
Timothy Jaeryang Baek db80dd2692 feat: prompt history table 2026-01-24 02:38:57 +04:00
Tim Baek 0dc74a8a2e refac 2026-01-23 08:33:21 -05:00
Tim Baek 90a057f400 refac 2026-01-23 08:25:19 -05:00
Timothy Jaeryang Baek 78f856e204 enh: quick prompt copy 2026-01-23 16:40:11 +04:00
Tim Baek d2c695eb11 feat: add convert_output_to_messages for OR-aligned message building 2026-01-22 19:54:05 -05:00
Timothy Jaeryang Baek 46cf40ec82 refac 2026-01-23 04:31:39 +04:00
Timothy Jaeryang Baek 655420fd25 feat: ENABLE_OAUTH_TOKEN_EXCHANGE 2026-01-23 04:25:39 +04:00
Timothy Jaeryang Baek 52c73390f8 refac 2026-01-23 01:44:48 +04:00
Timothy Jaeryang Baek c46ef3b63b enh: builtin tools model editor 2026-01-23 01:40:18 +04:00
G30 443908b14c fix: enforce Allow Chat Valves permission in integrations menu (#20691)
- The "Allow Chat Valves" permission was only hiding valves in the Chat Controls sidebar, but users could still bypass this by accessing valves through the integrations menu in the chat input field. This fix adds the permission check to hide Valves buttons for both tools and filters/functions in the integrations menu.
2026-01-22 16:25:00 -05:00
Timothy Jaeryang Baek 3bec320bb9 refac 2026-01-23 01:23:34 +04:00
Mikhail GubenkoandCopilot fa6f238777 docs: update pr template with critical checks and improve guidelines (#20872)
* docs: update pull request template with critical checks and improved guidelines

* Update .github/pull_request_template.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* docs: solidify pr checklist definitions and statements

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-22 16:20:18 -05:00
Tim Baek 86e6b2b68b refac: audit 2026-01-22 16:18:16 -05:00
Timothy Jaeryang Baek 14e51e0977 refac 2026-01-23 01:02:41 +04:00
Timothy Jaeryang Baek 4c6f100b5f refac 2026-01-23 00:56:50 +04:00
Classic298 5a0488bb18 init (#20881) 2026-01-22 20:30:07 +04:00
Timothy Jaeryang Baek 9af40624c5 refac 2026-01-22 18:58:00 +04:00
Timothy Jaeryang Baek 0df561c33c refac 2026-01-22 18:40:54 +04:00
Timothy Jaeryang Baek c7f996d593 refac: AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL for mcp connections 2026-01-22 18:30:16 +04:00
Timothy Jaeryang Baek 907dba4517 feat: easter eggs flag 2026-01-22 18:27:42 +04:00
G30 9e5d6069fe feat: Sort Tools and Functions dropdowns alphabetically (#20871)
Alphabetically sort Tools and Functions selection dropdowns in Chat Controls sidebar for easier navigation and better user experience.
2026-01-22 07:07:07 -05:00
Timothy Jaeryang Baek 14f6747dfc refac 2026-01-22 15:07:14 +04:00
Timothy Jaeryang Baek 68b2872ed6 fix/refac: file batch process issue 2026-01-22 15:03:31 +04:00
Timothy Jaeryang Baek 1a4bdd2b30 refac 2026-01-22 14:59:15 +04:00
Timothy Jaeryang Baek 886c12c566 refac 2026-01-22 14:54:00 +04:00
Timothy Jaeryang Baek a3600e8b21 refac 2026-01-22 04:12:57 +04:00
Timothy Jaeryang Baek 5d48e48e15 fix: ENABLE_PERSISTENT_CONFIG redis issue 2026-01-22 03:59:00 +04:00
Timothy Jaeryang Baek 474427c67e enh: dynamic select options valve 2026-01-22 03:55:07 +04:00
Classic298 00b3583dc2 fix: fix reindex not working due to unnecessary dupe check (#20857)
* Update retrieval.py

* Update knowledge.py

* Update retrieval.py

* Update knowledge.py
2026-01-21 18:36:08 -05:00
Timothy Jaeryang Baek 4d9a7cc6c0 refac: fr-FR priority 2026-01-22 03:25:23 +04:00
Timothy Jaeryang Baek 509bd2bebb refac/fix: default oauth2.1 mcp tool 2026-01-22 03:20:59 +04:00
Timothy Jaeryang Baek 8c70453b2e enh: password valve type 2026-01-22 03:18:38 +04:00
Timothy Jaeryang Baek 8eebc2aea6 fix: mcp get_discovery_urls 2026-01-22 03:11:33 +04:00
Timothy Jaeryang Baek a9a0ce6bea refac 2026-01-22 03:09:04 +04:00
Timothy Jaeryang Baek ecbdef732b enh: PDF_LOADER_MODE 2026-01-21 23:51:36 +04:00
Timothy Jaeryang Baek 4615e8f92b refac 2026-01-20 22:28:10 +04:00
Timothy Jaeryang Baek 91faa9fd5a refac 2026-01-20 16:42:20 +04:00
Timothy Jaeryang Baek 85e92fe3b0 refac 2026-01-20 16:41:46 +04:00
Classic298 38bf0b6eec feat: Add new ENV VAR for custom error message on error on signup / password change due to password not meeting requirements (#20650)
* add env var for custom auth pw message

* Update auth.py

* Update auth.py
2026-01-19 14:00:48 +04:00
G30 6ae3ddd66b fix: truncate long model names and IDs in UI (#20696)
- Added line-clamp-1 truncation and tooltips to long model names and IDs in the Evaluations and Models admin sections to prevent visual overflow. Model names now display truncated with full name and ID available on hover.
2026-01-19 13:59:44 +04:00
G30 98cb2d3411 feat: Add MATLAB syntax highlighting support for code blocks (#20773)
* feat: Add MATLAB syntax highlighting support for code blocks

Add MATLAB syntax highlighting support by fixing issues in both CodeEditor (editable) and CodeBlock (read-only) components.

Changes:
- CodeEditor.svelte: Added 'matlab' alias to CodeMirror's Octave language (MATLAB-compatible syntax)
- CodeBlock.svelte: Fixed highlight.js usage to use highlight() directly when language is recognized, falling back to highlightAuto() only for unknown languages

* revert: not needed
2026-01-19 13:58:48 +04:00
Timothy Jaeryang Baek be75bc506a refac 2026-01-19 13:49:12 +04:00
7a42efec53 i18n: Update translation.json (ja-JP) (#20772)
* i18n: Update Japanese translation

* i18n: Update Japanese translation

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-19 13:42:58 +04:00
G30 e9926694c3 fix: add username search support to workspace and admin pages (#20780)
This fix restores and extends the username/email search functionality across workspace pages that was originally added in PR #14002. The issue was that:

1. The backend search functions for Models and Knowledge only searched `User.name` and `User.email`, but not `User.username`

2. The Functions admin page lacked user search entirely

Changes made:

Added User.username to backend search conditions for Models and Knowledge pages
Added complete user search (name, email, username) to the Functions admin page client-side filter
2026-01-19 13:42:33 +04:00
Timothy Jaeryang Baek 5cfb7a08cb refac 2026-01-17 21:52:12 +04:00
9d642f6354 Added support for connecting to self hosted weaviate deployments using connect_to_custom replacing connect_to_local, which is better suited for cases where HTTP and GRPC are hosted on different ingresses. (#20620)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: rohithshenoyg@gmail.com <rohithshenoyg@gmail.com>
2026-01-17 21:48:52 +04:00
Classic298 716f2986b9 dep bump (#20735) 2026-01-17 21:44:32 +04:00
Timothy Jaeryang Baek 409f565f09 refac 2026-01-17 21:41:48 +04:00
Classic298 26e95f2a92 fix-csv-export (#20688) 2026-01-17 21:39:24 +04:00
Kylapaallikko 711a2cd738 Update translation.json (#20741)
Added missing translations and minor changes to wording of the translations.
2026-01-17 21:37:14 +04:00
Classic298 1c1f72f05c Update builtin.py (#20705) 2026-01-16 00:15:02 +04:00
1d343aeae4 enh: Make builtin search web tools asynchronous (#20630)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-15 10:46:00 +04:00
Kailey Wong e26f6acc3b fix: use proper X-Api-Key header format when docling api key provided (#20652) 2026-01-15 10:44:35 +04:00
G30 1555252c4a fix: handle undefined model in createMessagePair function (#20663)
- Add a null check when looking up model to prevent JavaScript error when trying to add a message pair in a chat with an invalid/corrupt model ID.
2026-01-15 10:43:25 +04:00
Timothy Jaeryang Baek de0cbb9073 refac 2026-01-12 21:56:02 +04:00
Timothy Jaeryang Baek 5a075a2c83 fix: members only groups 2026-01-12 21:53:41 +04:00
Timothy Jaeryang Baek 7da37b4f66 refac 2026-01-12 21:41:23 +04:00
6f80cb6b65 i18n: Update Japanese translation (#20605)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-12 20:34:32 +04:00
Shirasawa 9617df04ae I18n: improve Chinese translation (#20613)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-01-12 20:34:13 +04:00
joaoback 01d5f42755 Update translation.json (pt-BR) (#20599)
translation of the new items recently added
2026-01-12 14:04:09 +04:00
Classic298 84d76cccde Update translation.json (#20593) 2026-01-12 00:41:28 +04:00
Classic298 af584b46f4 feat: code-interpreter native (#20592)
* code-interpreter native

* Update tools.py

* Update builtin.py

* Update builtin.py

* Update tools.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py
2026-01-12 00:18:41 +04:00
_00_ 0fb4cceec1 UPD: i18n: Spanish es-ES Translation v0.7.0 (#20564)
### UPD: i18n: Spanish es-ES Translation v0.7.0

- Added new strings
2026-01-11 23:39:52 +04:00
Classic298 1dc353433a fix(db): release connection before embedding in memory /query (#20579)
Remove Depends(get_session) from POST /query endpoint to prevent database connections from being held during embedding API calls (1-5+ seconds).

The Memories.get_memories_by_user_id() function manages its own short-lived session internally, releasing the connection before the slow EMBEDDING_FUNCTION() call begins.
2026-01-11 23:37:47 +04:00
Classic298 33e8a09880 fix(db): release connection before embedding in knowledge /create (#20575)
Remove Depends(get_session) from POST /create endpoint to prevent database connections from being held during embedding API calls (1-5+ seconds).

The has_permission() and Knowledges.insert_new_knowledge() functions manage their own short-lived sessions internally, releasing connections before the slow embed_knowledge_base_metadata() call begins.
2026-01-11 23:37:05 +04:00
Classic298 1cb751d184 fix(db): release connection before embedding in knowledge /{id}/update (#20574)
Remove Depends(get_session) from POST /{id}/update endpoint to prevent database connections from being held during embedding API calls (1-5+ seconds).

All database operations (get_knowledge_by_id, has_access, has_permission, update_knowledge_by_id, get_file_metadatas_by_id) manage their own short-lived sessions internally, releasing connections before and after the slow embed_knowledge_base_metadata() call.
2026-01-11 23:36:36 +04:00
Classic298 9e596f8616 fix(db): release connection before LLM call in Ollama /v1/completions (#20570)
Remove Depends(get_session) from the /v1/completions endpoint to prevent database connections from being held during the entire duration of LLM calls.

Previously, the database session was acquired at request start and held until the response completed. Under concurrent load, this exhausted the connection pool, causing QueuePool timeout errors.

The fix allows Models.get_model_by_id() and has_access() to manage their own short-lived sessions internally, releasing the connection immediately after authorization checks complete.
2026-01-11 23:35:46 +04:00
Classic298 24044b42ea fix(db): release connection before LLM call in Ollama /v1/chat/completions (#20569)
Remove Depends(get_session) from the /v1/chat/completions endpoint to prevent database connections from being held during the entire duration of LLM calls.

Previously, the database session was acquired at request start and held until the streaming response completed. Under concurrent load, this exhausted the connection pool, causing QueuePool timeout errors.

The fix allows Models.get_model_by_id() and has_access() to manage their own short-lived sessions internally, releasing the connection immediately after authorization checks complete.
2026-01-11 23:35:38 +04:00
Aleix Dorca 84263fc6a6 i18n: Updated the Catalan translation file (#20566)
* Update translation.json

* Update catalan translation.json
2026-01-11 23:35:11 +04:00
2faab409d3 i18n(pl-PL): Add missing keys and update existing translations (#20562)
* Added lacking translations to Polish locale and improved some existing ones for better clarity.

* fix: add missing newline at EOF

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-11 23:35:01 +04:00
Classic298 0b5aa6dd60 fix(db): release connection before LLM call in Ollama /api/chat (#20571)
Remove Depends(get_session) from the /api/chat endpoint to prevent database connections from being held during the entire duration of LLM calls (30-60+ seconds for streaming responses).

Previously, the database session was acquired at request start and held until the streaming response completed. Under concurrent load, this exhausted the connection pool, causing QueuePool timeout errors for other database operations.

The fix allows Models.get_model_by_id() and has_access() to manage their own short-lived sessions internally, releasing the connection immediately after the quick authorization checks complete - before the slow external LLM API call begins.
2026-01-11 23:34:23 +04:00
Classic298 d0c2bfdbff fix(db): release connection before LLM call in OpenAI /chat/completions (#20572)
Remove Depends(get_session) from the /chat/completions endpoint to prevent database connections from being held during the entire duration of LLM calls (30-60+ seconds for streaming responses).

Previously, the database session was acquired at request start and held until the streaming response completed. Under concurrent load, this exhausted the connection pool, causing QueuePool timeout errors for other database operations.

The fix allows Models.get_model_by_id() and has_access() to manage their own short-lived sessions internally, releasing the connection immediately after the quick authorization checks complete - before the slow external LLM API call begins.
2026-01-11 23:34:11 +04:00
Classic298 242625782f fix(db): release connection before embedding in memory /add (#20578)
Remove Depends(get_session) from POST /add endpoint to prevent database connections from being held during embedding API calls (1-5+ seconds).

The Memories.insert_new_memory() function manages its own short-lived session internally, releasing the connection before the slow EMBEDDING_FUNCTION() call begins.
2026-01-11 23:33:17 +04:00
Classic298 826e9ab317 fix(db): release connection before embeddings in knowledge /metadata/reindex (#20577)
Remove Depends(get_session) from POST /metadata/reindex endpoint to prevent database connections from being held during N embedding API calls.

This endpoint is CRITICAL as it loops through ALL knowledge bases and calls embed_knowledge_base_metadata() for each one. With the original code, a single connection would be held for the entire duration (potentially minutes for large deployments), completely exhausting the pool.

The Knowledges.get_knowledge_bases() function manages its own short-lived session, releasing the connection before the embedding loop begins.
2026-01-11 23:33:04 +04:00
Classic298 182d5e8591 fix(db): release connection before embedding in process_files_batch (#20576)
Remove Depends(get_session) from POST /process/files/batch endpoint to prevent database connections from being held during batch embedding API calls (5-60+ seconds for large batches).

The save_docs_to_vector_db() function makes external embedding API calls. Post-embedding file updates (Files.update_file_by_id) manage their own short-lived sessions internally, releasing connections promptly.
2026-01-11 23:32:56 +04:00
Classic298 3fc866117d fix(db): CRITICAL - prevent pool exhaustion in memory /reset (#20580)
Remove Depends(get_session) from POST /reset to prevent catastrophic connection pool exhaustion.

This endpoint was holding a SINGLE database connection while executing N PARALLEL embedding API calls via asyncio.gather(). For a user with 100 memories, this meant one connection blocked for potentially MINUTES (100 calls * 1-5 seconds each, even in parallel due to rate limits).

A single user triggering /reset could completely starve the connection pool, causing QueuePool timeout errors across the entire application.

The Memories.get_memories_by_user_id() function now manages its own short-lived session, releasing the connection immediately before the massive parallel embedding operation begins.
2026-01-11 23:32:40 +04:00
Classic298 b464b48f53 Merge pull request #20581 from Classic298/fix/db-pool-memory-update
fix(db): release connection before embedding in memory /{memory_id}/update
2026-01-11 23:32:27 +04:00
Tim Baek 2b26355002 Merge pull request #20560 from open-webui/dev
0.7.2
2026-01-11 01:00:01 +04:00
Timothy Jaeryang Baek d81a36310c chore: bump 2026-01-11 00:58:57 +04:00
Timothy Jaeryang Baek d56bb2c383 refac 2026-01-11 00:52:43 +04:00
Timothy Jaeryang Baek 2dd09223f2 refac/fix: duplicated api calls for evaluation 2026-01-10 23:58:40 +04:00
Timothy Jaeryang Baek 0c369d195b chore: format 2026-01-10 23:50:18 +04:00
Timothy Jaeryang Baek ab99d3b112 fix: prompt creation issue 2026-01-10 23:40:07 +04:00
Classic298 3f133fad56 fix: release database connections immediately after auth instead of holding during LLM calls (#20545)
fix: release database connections immediately after auth instead of holding during LLM calls

Authentication was using Depends(get_session) which holds a database connection
for the entire request lifecycle. For chat completions, this meant connections
were held for 30-60 seconds while waiting for LLM responses, despite only needing
the connection for ~50ms of actual database work.

With a default pool of 15 connections, this limited concurrent chat users to ~15
before pool exhaustion and timeout errors:

    sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
    connection timed out, timeout 30.00

The fix removes Depends(get_session) from get_current_user. Each database
operation now manages its own short-lived session internally:

    BEFORE: One session held for entire request
    ──────────────────────────────────────────────────
    │ auth │ queries │ LLM wait (30s) │ save │
    │         CONNECTION HELD ENTIRE TIME            │
    ──────────────────────────────────────────────────

    AFTER: Short-lived sessions, released immediately
    ┌──────┐ ┌───────┐                 ┌──────┐
    │ auth │ │ query │   LLM (30s)     │ save │
    │ 10ms │ │ 20ms  │  NO CONNECTION  │ 20ms │
    └──────┘ └───────┘                 └──────┘

This is safe because:
- User model has no lazy-loaded relationships (all simple columns)
- Pydantic conversion (UserModel.model_validate) happens while session is open
- Returned object is pure Pydantic with no SQLAlchemy ties

Combined with the telemetry efficiency fix, this resolves connection pool
exhaustion for high-concurrency deployments, particularly on network-attached
databases like AWS Aurora where connection hold time is more impactful.
2026-01-10 15:34:36 +04:00
Classic298 41d1ccd39c Update channels.py (#20546) 2026-01-10 15:34:12 +04:00
Classic298 7839d043ff fix: use efficient COUNT queries in telemetry metrics to prevent connection pool exhaustion (#20542)
fix: use efficient COUNT queries in telemetry metrics to prevent connection pool exhaustion

This fixes database connection pool exhaustion issues reported after v0.7.0,
particularly affecting PostgreSQL deployments on high-latency networks (e.g., AWS Aurora).

## The Problem

The telemetry metrics callbacks (running every 10 seconds via OpenTelemetry's
PeriodicExportingMetricReader) were using inefficient queries that loaded entire
database tables into memory just to count records:

    len(Users.get_users()["users"])  # Loads ALL user records to count them

On high-latency network-attached databases like AWS Aurora, this would:
1. Hold database connections for hundreds of milliseconds while transferring data
2. Deserialize all records into Python objects
3. Only then count the list length

Under concurrent load, these long-held connections would stack up and drain the
connection pool, resulting in:

    sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
    connection timed out, timeout 30.00

## The Fix

Replace inefficient full-table loads with efficient COUNT(*) queries using
methods that already exist in the codebase:

- `len(Users.get_users()["users"])` → `Users.get_num_users()`
- Similar changes for other telemetry callbacks as needed

COUNT(*) queries use database indexes and return a single integer, completing in
~5-10ms even on Aurora, versus potentially 500ms+ for loading all records.

## Why v0.7.1's Session Sharing Disable "Helped"

The v0.7.1 change to disable DATABASE_ENABLE_SESSION_SHARING by default appeared
to fix the issue, but it was masking the root cause. Disabling session sharing
causes connections to be returned to the pool faster (more connection churn),
which reduced the window for pool exhaustion but didn't address the underlying
inefficient queries.

With this fix, session sharing can be safely re-enabled for deployments that
benefit from it (especially PostgreSQL), as telemetry will no longer hold
connections for extended periods.

## Impact

- Telemetry connection usage drops from potentially seconds to ~30ms total per
  collection cycle
- Connection pool pressure from telemetry becomes negligible (~0.3% utilization)
- Enterprise PostgreSQL deployments (Aurora, RDS, etc.) should no longer
  experience pool exhaustion under normal load
2026-01-10 15:33:42 +04:00
G30 9b9e6ce2ab fix: correct empty STT_ENGINE handling and improve TTS error response (#20534)
- Remove incorrect 403 check that blocked STT when ENGINE="" (local whisper)
- Change TTS empty ENGINE check from 403 to 404 for proper semantics
2026-01-10 15:32:22 +04:00
Classic298 81510e9d8f fix(files): prevent connection pool exhaustion in file status streaming (#20547)
Refactored the file processing status streaming endpoint to avoid holding
a database connection for the entire stream duration (up to 2 hours).
Changes:
- Each status poll now creates its own short-lived database session instead
  of capturing the request's session in the generator closure
- Increased poll interval from 0.5s to 1s, halving database queries with
  negligible UX impact
This prevents a single file status stream from blocking a connection pool
slot for hours, which could contribute to pool exhaustion under load.
2026-01-10 15:23:48 +04:00
EntropyYue c0ff925c2a fix: Settings tab i18n (#20526) 2026-01-10 12:41:06 +04:00
joaoback 2da661fed1 Update translation.json (pt-BR) (#20527)
Translations of the new items added in the latest versions.
2026-01-10 12:37:59 +04:00
G30 59b128bbda Update ChannelItem.svelte (#20535) 2026-01-10 12:37:42 +04:00
Tim Baek f2a360cb87 Merge pull request #20523 from open-webui/dev
chore
2026-01-10 00:57:39 +04:00
Tim Baek 8deef788c1 chore 2026-01-09 15:57:06 -05:00
Tim Baek f9b0534e0c Merge pull request #20522 from open-webui/dev
0.7.1
2026-01-10 00:44:36 +04:00
Timothy Jaeryang Baek c4de5ea50c chore: bump 2026-01-10 00:40:37 +04:00
Timothy Jaeryang Baek 8646aebaab refac/fix: DATABASE_ENABLE_SESSION_SHARING env var 2026-01-10 00:16:04 +04:00
353 changed files with 32295 additions and 6819 deletions
+18 -4
View File
@@ -1,3 +1,9 @@
<!--
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
-->
# Pull Request Checklist
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
@@ -6,14 +12,16 @@ This is to ensure large feature PRs are discussed with the community first, befo
**Before submitting, make sure you've checked the following:**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **Not targeting the `dev` branch will lead to immediate closure of the PR.**
- [ ] **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:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, or other documentation sources.
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Take this as an opportunity to **make screenshots of the feature/fix and include it in 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
@@ -76,6 +84,12 @@ This is to ensure large feature PRs are discussed with the community first, befo
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
-->
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
+223
View File
@@ -5,6 +5,229 @@ 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.8.2] - 2026-02-16
### Added
- 🧠 **Skill content handling.** User-selected skills now have their full content injected into the chat, while model-attached skills only display name and description in the available skills list. This allows users to override skill behavior while model-attached skills remain flexible. [Commit](https://github.com/open-webui/open-webui/commit/393c0071dc612c5ac982fb37dfc0288cb9911439)
- ⚙️ **Chat toggles now control built-in tools.** Users can now disable web search, image generation, and code execution on a per-conversation basis, even when those tools are enabled as builtin tools on the model. [#20641](https://github.com/open-webui/open-webui/issues/20641), [#21318](https://github.com/open-webui/open-webui/discussions/21318), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c), [Commit](https://github.com/open-webui/open-webui/commit/f1a1e64d2e9ad953b2bc2a9543e9a308b7c669c8)
- 🖼️ **Image preview in file modal.** Images uploaded to chats can now be previewed directly in the file management modal, making it easier to identify and manage image files. [#21413](https://github.com/open-webui/open-webui/issues/21413), [Commit](https://github.com/open-webui/open-webui/commit/e1b3e7252c1896c04d498547908f0fce111434e1)
- 🏷️ **Batch tag operations.** Tag creation, deletion, and orphan cleanup for chats now use batch database queries instead of per-tag loops, significantly reducing database round trips when updating, archiving, or deleting chats with multiple tags. [Commit](https://github.com/open-webui/open-webui/commit/c748c3ede)
- 💨 **Faster group list loading.** Group lists and search results now load with a single database query that joins member counts, replacing the previous pattern of fetching groups first and then counting members in a separate batch query. [Commit](https://github.com/open-webui/open-webui/commit/33308022f)
- 🔐 **Skills sharing permissions.** Administrators can now control skills sharing and public sharing permissions per-group, matching the existing capabilities for tools, knowledge, and prompts. [Commit](https://github.com/open-webui/open-webui/commit/88401e91c)
- ⚡ **Long content truncation in preview modals.** Citation and file content modals now truncate markdown-rendered content at 10,000 characters with a "Show all" expansion button, preventing UI jank when previewing very large documents.
- 🌐 **Translation updates.** Translations for Spanish and German were enhanced and expanded.
### Fixed
- 🔐 **OAuth session error handling.** Corrupted OAuth sessions are now gracefully handled and automatically cleaned up instead of causing errors. [Commit](https://github.com/open-webui/open-webui/commit/7e224e4a536b07ec008613f06592e34050e7067c)
- 🐛 **Task model selector validation.** The task model selector in admin settings now correctly accepts models based on the new access grants system instead of rejecting all models with an incorrect error. [Commit](https://github.com/open-webui/open-webui/commit/9a2595f0706d0c9d809ae7746001cf799f98db1d)
- 🔗 **Tool call message preservation.** Models no longer hallucinate tool outputs in multi-turn conversations because tool call history is now properly preserved instead of being merged into assistant messages. [#21098](https://github.com/open-webui/open-webui/discussions/21098), [#20600](https://github.com/open-webui/open-webui/issues/20600), [Commit](https://github.com/open-webui/open-webui/commit/f2aca781c87244cffc130aa2722e700c19a81d66)
- 🔧 **Tool server startup initialization.** External tool servers configured via the "TOOL_SERVER_CONNECTIONS" environment variable now initialize automatically on startup, eliminating the need to manually visit the Admin Panel and save for tools to become available. This enables proper GitOps and containerized deployments. [#18140](https://github.com/open-webui/open-webui/issues/18140), [#20914](https://github.com/open-webui/open-webui/pull/20914), [Commit](https://github.com/open-webui/open-webui/commit/f20cc6d7e6da493eb75ca1618f5cbd068fa57684)
- ♻️ **Resource handle cleanup.** File handles are now properly closed during audio transcription and pipeline uploads, preventing resource leaks that could cause system instability over time. [#21411](https://github.com/open-webui/open-webui/issues/21411)
- ⌨️ **Strikethrough shortcut conflict fix.** Pressing Ctrl+Shift+S to toggle the sidebar no longer causes text to become struck through in the chat input, by disabling the TipTap Strike extension's default keyboard shortcut when rich text mode is off. [Commit](https://github.com/open-webui/open-webui/commit/38ae91ae2)
- 🔧 **Tool call finish_reason fix.** API responses now correctly set finish_reason to "tool_calls" instead of "stop" when tool calls are present, fixing an issue where external API clients (such as OpenCode) would halt prematurely after tool execution when routing Ollama models through the Open WebUI API. [#20896](https://github.com/open-webui/open-webui/issues/20896)
## [0.8.1] - 2026-02-14
### Added
- 🚀 **Channel user active status.** Checking user active status in channels is now faster thanks to optimized database queries. [Commit](https://github.com/open-webui/open-webui/commit/ca6b18ab5cb94153a9dae233f975d36bf6b19b76)
- 🔗 **Responses API endpoint with model routing.** The OpenAI API proxy now supports a /responses endpoint that routes requests to the correct backend based on the model field in the request, instead of always using the first configured endpoint. This enables support for backends like vLLM that provide /skills and /v1/responses endpoints. [Commit](https://github.com/open-webui/open-webui/commit/abc9b63093d65f4d74342db85b7d5df1809aa0f0), [Commit](https://github.com/open-webui/open-webui/commit/79ecbfc757f0642740d0e44fab98263d84295490)
- ⚡ **Model and prompt list optimization.** Improved performance when loading models and prompts by pre-fetching user group IDs once instead of making multiple database queries. [Commit](https://github.com/open-webui/open-webui/commit/20de5a87da0c12e4052b50887a42ddd7228c5ef5)
- 🗄️ **Batch access control queries.** Improved performance when loading models, prompts, and knowledge bases by replacing multiple individual access checks with single batch queries, significantly reducing database load for large deployments. [Commit](https://github.com/open-webui/open-webui/commit/589c4e64c1b7bb7a7a5abc20382b92fb860e28c2)
- 💨 **Faster user list loading.** User lists now load significantly faster by deferring profile image loading; images are fetched separately in parallel by the browser, improving caching and reducing database load. [Commit](https://github.com/open-webui/open-webui/commit/b7549d2f6ca2843661ec79a5a1e55da9e7553368)
- 🔍 **Web search result count.** The built-in search_web tool now respects the admin-configured "Search Result Count" setting instead of always returning 5 results when using Native Function Calling mode. [#21373](https://github.com/open-webui/open-webui/pull/21373), [#21371](https://github.com/open-webui/open-webui/issues/21371)
- 🔐 **SCIM externalId support.** SCIM-enabled deployments can now store and manage externalId for user provisioning, enabling better integration with identity providers like Microsoft Entra ID and Okta. [#21099](https://github.com/open-webui/open-webui/pull/21099), [#21280](https://github.com/open-webui/open-webui/issues/21280), [Commit](https://github.com/open-webui/open-webui/commit/d1d1efe212b16e0052359991d67fd813125077e8)
- 🌐 **Translation updates.** Portuguese (Brazil) translations were updated.
### Fixed
- 🛡️ **Public sharing security fix.** Fixed a security issue where users with write access could see the Public sharing option regardless of their actual public sharing permission, and direct API calls could bypass frontend sharing restrictions. [#21358](https://github.com/open-webui/open-webui/pull/21358), [#21356](https://github.com/open-webui/open-webui/issues/21356)
- 🔒 **Direct model access control fix.** Model access control changes now persist correctly for direct Ollama and OpenAI models that don't have database entries, and error messages display properly instead of showing "[object Object]". [Commit](https://github.com/open-webui/open-webui/commit/f027a01ab2ff3b6175af3dd13a4478c265c0544a), [#21377](https://github.com/open-webui/open-webui/issues/21377)
- 💭 **Reasoning trace rendering performance.** Reasoning traces from models now render properly without being split into many fragments, preventing browser slowdowns during streaming responses. [#21348](https://github.com/open-webui/open-webui/issues/21348), [Commit](https://github.com/open-webui/open-webui/commit/3b61562c82448cf83710d8b6ed29b797991aa83a)
- 🖥️ **ARM device compatibility fix.** Fixed an issue where upgrading to 0.8.0 would fail to start on ARM devices (like Raspberry Pi 4) due to torch 2.10.0 causing SIGILL errors; now pinned to torch<=2.9.1. [#21385](https://github.com/open-webui/open-webui/pull/21385), [#21349](https://github.com/open-webui/open-webui/issues/21349)
- 🗄️ **Skills PostgreSQL compatibility fix.** Fixed a PostgreSQL compatibility issue where creating or listing skills would fail with a TypeError, while SQLite worked correctly. [#21372](https://github.com/open-webui/open-webui/pull/21372), [Commit](https://github.com/open-webui/open-webui/commit/b4c3f54f9648c4232a0fd6557703ffa66fcf4caa), [#21365](https://github.com/open-webui/open-webui/issues/21365)
- 🗄️ **PostgreSQL analytics query fix.** Fixed an issue where retrieving chat IDs by model ID would fail on PostgreSQL due to incompatible DISTINCT ordering, while SQLite worked correctly. [#21347](https://github.com/open-webui/open-webui/issues/21347), [Commit](https://github.com/open-webui/open-webui/commit/7bda6bf767d5d5c4dc1111465096a88e10b5030e)
- 🗃️ **SQLite cascade delete fix.** Deleting chats now properly removes all associated messages in SQLite, matching PostgreSQL behavior and preventing orphaned data. [#21362](https://github.com/open-webui/open-webui/pull/21362)
- ☁️ **Ollama Cloud model naming fix.** Fixed an issue where using Ollama Cloud models would fail with "Model not found" errors because ":latest" was incorrectly appended to model names. [#21386](https://github.com/open-webui/open-webui/issues/21386)
- 🛠️ **Knowledge selector tooltip z-index.** Fixed an issue where tooltips in the "Select Knowledge" dropdown were hidden behind the menu, making it difficult to read knowledge item names and descriptions. [#21375](https://github.com/open-webui/open-webui/pull/21375)
- 🎯 **Model selector scroll position.** The model selector dropdown now correctly scrolls to and centers the currently selected model when opened, and resets scroll position when reopened. [Commit](https://github.com/open-webui/open-webui/commit/0b05b2fc7ed4c38af158707438ff404d1beb7c91)
- 🐛 **Sync modal unexpected appearance.** Fixed an issue where the Sync Modal would appear unexpectedly after enabling the "Community Sharing" feature if the user had previously visited the app with the sync parameter. [#21376](https://github.com/open-webui/open-webui/pull/21376)
- 🎨 **Knowledge collection layout fix.** Fixed a layout issue in the Knowledge integration menu where long collection names caused indentation artifacts and now properly truncate with ellipsis. [#21374](https://github.com/open-webui/open-webui/pull/21374)
- 📝 **Metadata processing crash fix.** Fixed a latent bug where processing document metadata containing certain keys (content, pages, tables, paragraphs, sections, figures) would cause a RuntimeError due to dictionary mutation during iteration. [#21105](https://github.com/open-webui/open-webui/pull/21105)
- 🔑 **Password validation regex fix.** Fixed the password validation regex by adding the raw string prefix, ensuring escape sequences like \d and \w are interpreted correctly. [#21400](https://github.com/open-webui/open-webui/pull/21400), [#21399](https://github.com/open-webui/open-webui/issues/21399)
### 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.8.0] - 2026-02-12
### Added
- 📊 **Analytics dashboard.** Administrators now have access to an Analytics dashboard showing model usage statistics, token consumption by model and user, user activity rankings, and time-series charts with hourly or daily granularity; clicking any model opens a detail view with feedback history, associated tags, and chat browser, and results can be filtered by user group. [#21106](https://github.com/open-webui/open-webui/pull/21106), [Commit](https://github.com/open-webui/open-webui/commit/68a1e87b66a7ec8831d5ed52940c4ef110e3e264), [Commit](https://github.com/open-webui/open-webui/commit/e62649f94044abfed4d7d60647a2050383a67e3d)
- 🎯 **Experimental support for Skills.** Open WebUI now supports the Skill standard — allowing users to create and manage reusable AI skills with detailed instructions, reference them in chats using the "$" command, or attach them to specific models for automatic context in conversations. [#21312](https://github.com/open-webui/open-webui/pull/21312)
- 🧪 **Experimental support for Open Responses protocol.** Connections can now be configured to use the experimental Open Responses protocol instead of Chat Completions, enabling native support for extended thinking, streaming reasoning tokens, and richer tool call handling for compatible providers. [Commit](https://github.com/open-webui/open-webui/commit/d2c695eb11ddca9fc93499bb0c3fcafcff7099b5), [Commit](https://github.com/open-webui/open-webui/commit/90a057f4005c000bda6ff8703e13e529190af73a), [Commit](https://github.com/open-webui/open-webui/commit/0dc74a8a2e7adb76fb503ef0cd3c02daddd2f4bb), [Commit](https://github.com/open-webui/open-webui/commit/ea9c58ea80646cef05e06d0beaf5e81cc2f78cb1), [Commit](https://github.com/open-webui/open-webui/commit/6ffce4bccdc13b8b61a8b286e34094c981932eda), [Commit](https://github.com/open-webui/open-webui/commit/6719558150920f570d8febe021da65903e53c976), [Commit](https://github.com/open-webui/open-webui/commit/117c091b95a1b1a76a31c31b97304bac289d6f18), [Commit](https://github.com/open-webui/open-webui/commit/aa8c2959ca8476f269786e1317fb6d2938abd3f9), [Commit](https://github.com/open-webui/open-webui/commit/e2d09ac36174de48a7d85bafc8d3291c9ffe44cd)
- 👥 **Redesigned access control UI.** The access control UI was redesigned with a more intuitive interface that makes it easier to add multiple groups at once. [#21277](https://github.com/open-webui/open-webui/pull/21277)
- 👤 **Per-user resource sharing.** Resources including knowledge bases, prompts, models, tools, channels, and base models can now be shared directly to individual users alongside the existing per-group sharing capability. [#21277](https://github.com/open-webui/open-webui/pull/21277)
- 📨 **Message queuing.** Messages can now be queued while a response is generating rather than being blocked, allowing you to continue your train of thought; queued messages are automatically combined and sent when generation completes, and can be edited, deleted, or sent immediately from the input area. [Commit](https://github.com/open-webui/open-webui/commit/62750b8980ef0a3f2da7bc64b5416706a7495686), [Commit](https://github.com/open-webui/open-webui/commit/d3f2cf74748db42311ca04a56ccd1ea15399eca0)
- 💡 **Active task sidebar indicator.** Users can now see which chats have active tasks running directly in the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/48522271586a5bf24b649610f03b4ffd8afb2782)
- 📝 **Prompt version control.** Prompts now include version control with full history tracking, allowing users to commit changes with messages, view past versions, compare differences between versions, and roll back to previous versions when needed. [#20945](https://github.com/open-webui/open-webui/pull/20945)
- 🏷️ **Prompt tags.** Prompts can now be organized with tags, and users can filter the prompt workspace by tag to quickly find related prompts across large collections. [#20945](https://github.com/open-webui/open-webui/pull/20945)
- 🐍 **Native function calling code execution.** Code execution now works with Native function calling mode, allowing models to autonomously run Python code for calculations, data analysis, and visualizations without requiring Default mode. [#20592](https://github.com/open-webui/open-webui/pull/20592), [Docs:#998](https://github.com/open-webui/docs/pull/998)
- 🚀 **Async web search.** Web search operations now run asynchronously in the background, allowing users to continue interacting with the application while searches complete. [#20630](https://github.com/open-webui/open-webui/pull/20630)
- ⚡ **Search debouncing.** Search operations across the application now respond more efficiently with debouncing that reduces unnecessary server requests while typing, improving responsiveness when searching users, groups, functions, tools, prompts, knowledge bases, notes, and when using the knowledge and prompts commands in chat. [#20982](https://github.com/open-webui/open-webui/pull/20982), [Commit](https://github.com/open-webui/open-webui/commit/36766f157d46102fd76c526b42579400ca70de50), [Commit](https://github.com/open-webui/open-webui/commit/fa859de460376782bd0fa35512c8426c9cd0462c), [Commit](https://github.com/open-webui/open-webui/commit/57ec2aa088ffd5a8c3553c53d39799497ff70479)
- 🤝 **Shared chats management.** Users can now view and manage all their shared chats from Settings, with options to copy share links or unshare conversations they no longer want public. [Commit](https://github.com/open-webui/open-webui/commit/a10ac774ab5d47b505e840b029c0c0340002508b)
- 📁 **User file management.** Users can now view, search, and delete all their uploaded files from Settings, providing centralized file management in one place. [Commit](https://github.com/open-webui/open-webui/commit/93ed4ae2cda2f4311143e51f586aaa73b83a37a7), [#21047](https://github.com/open-webui/open-webui/pull/21047)
- 🗑️ **Shift-click quick delete.** Files in the File Manager can now be quickly deleted by holding Shift and clicking the delete button, bypassing the confirmation dialog for faster bulk cleanup. [#21044](https://github.com/open-webui/open-webui/pull/21044)
- ⌨️ **Model selector shortcut.** The model selector can now be opened with Ctrl+Shift+M keyboard shortcut. [#21130](https://github.com/open-webui/open-webui/pull/21130)
- 🧠 **Smarter knowledge vs web search.** Models now choose more intelligently between knowledge base search and web search rather than always trying knowledge first. [#21115](https://github.com/open-webui/open-webui/pull/21115)
- 🌍 **Community model reviews.** Users can now access community reviews for models directly from the model selector menu and are prompted to leave reviews after rating responses, with administrators able to disable this via the "Community Sharing" setting. [Commit](https://github.com/open-webui/open-webui/commit/bc90463ea60c9a66accb1fd242cf1853910ca838)
- 📄 **Prompts workspace pagination.** The prompts workspace now includes pagination for large prompt collections, loading 30 prompts at a time with search, filtering, and sorting capabilities for improved performance and navigation. [Commit](https://github.com/open-webui/open-webui/commit/36766f157d46102fd76c526b42579400ca70de50)
- 🎨 **Action function HTML rendering.** Action functions can now render rich HTML content directly in chat as embedded iframes, matching the capabilities that tools already had and eliminating the need for action authors to inject codeblocks. [#21294](https://github.com/open-webui/open-webui/pull/21294), [Commit](https://github.com/open-webui/open-webui/commit/60ada21c152ed642971429fdbe88dcbf478cf83a)
- 🔒 **Password-masked valve fields.** Tool and function developers can now mark sensitive fields as passwords, which are automatically masked in the settings UI to prevent shoulder surfing and accidental exposure. [#20852](https://github.com/open-webui/open-webui/issues/20852), [Commit](https://github.com/open-webui/open-webui/commit/8c70453b2e3a6958437d951751e84acbbaafd9aa)
- 📋 **Prompt quick copy.** Prompts in the workspace now include a quick copy button for easily copying prompt content to the clipboard. [Commit](https://github.com/open-webui/open-webui/commit/78f856e2049991441a3469230ae52799cb86954e)
- 🔔 **Dismissible notification toasts.** Notification toasts for new messages and other events now include a close button that appears on hover, allowing users to dismiss them immediately instead of waiting for auto-dismissal. [#21056](https://github.com/open-webui/open-webui/issues/21056), [Commit](https://github.com/open-webui/open-webui/commit/73bb600034c8532e30726129743a5ffe9002c5fb)
- 🔔 **Temporary chat notification privacy.** Notifications from temporary chats now only appear on the device where the chat is running, preventing privacy leaks across logged-in sessions. [#21292](https://github.com/open-webui/open-webui/pull/21292)
- 💡 **Null chat title fallback.** Notifications without chat titles now display "New Chat" instead of showing null. [#21292](https://github.com/open-webui/open-webui/pull/21292)
- 🖼️ **Concurrent image editing.** Image editing operations with multiple images now complete faster by loading all images concurrently instead of sequentially. [#20911](https://github.com/open-webui/open-webui/pull/20911)
- 📧 **USER_EMAIL template variable.** Users can now reference their email address in prompts and system messages using the "{{USER_EMAIL}}" template variable. [#20881](https://github.com/open-webui/open-webui/pull/20881)
- 🔤 **Alphabetical tool ordering.** Tools and Functions in the Chat Controls sidebar now appear in alphabetical order, making it easier to locate specific tools when working with multiple integrations. [#20871](https://github.com/open-webui/open-webui/pull/20871)
- 👁️ **Model list status filtering.** Administrators can now filter the model list by status (enabled, disabled, visible, hidden) and bulk enable or disable all filtered models at once. [#20553](https://github.com/open-webui/open-webui/issues/20553), [#20774](https://github.com/open-webui/open-webui/issues/20774), [Commit](https://github.com/open-webui/open-webui/commit/96a9696383d450dad2cbb230f3756ebfa258e029)
- ⚙️ **Per-model built-in tool toggles.** Administrators can now enable or disable individual built-in tools for each model, including time utilities, memory, chat history, notes, knowledge base, and channels. [#20641](https://github.com/open-webui/open-webui/issues/20641), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c)
- 📑 **PDF loading modes.** Administrators can now choose between "page" and "single" PDF loading modes, allowing documents to be processed as individual pages or as complete documents for better chunking across page boundaries. [Commit](https://github.com/open-webui/open-webui/commit/ecbdef732bc71a07c21bbb679edb420f26eac181)
- 📑 **Model Settings pagination.** Administrators can now navigate large model lists more efficiently in Model Settings, with pagination displaying 30 models per page for smoother navigation. [Commit](https://github.com/open-webui/open-webui/commit/2f584c9f88aeb34ece07b10d05794020d1d656b8)
- 📌 **Pin read-only models.** Users can now pin read-only models from the workspace. [#21308](https://github.com/open-webui/open-webui/issues/21308), [Commit](https://github.com/open-webui/open-webui/commit/97331bf11d41ca54e47f86777fb8dbd73988c631)
- 🔍 **Yandex search provider.** Administrators can now configure Yandex as a web search provider, expanding search engine options for retrieval-augmented generation. [#20922](https://github.com/open-webui/open-webui/pull/20922)
- 🔐 **Custom password hints.** Administrators can now provide custom password requirement hints to users via the "PASSWORD_VALIDATION_HINT" environment variable, making it clearer what password criteria must be met during signup or password changes. [#20647](https://github.com/open-webui/open-webui/issues/20647), [#20650](https://github.com/open-webui/open-webui/pull/20650)
- 🔑 **OAuth token exchange.** Administrators can now enable OAuth token exchange via "ENABLE_OAUTH_TOKEN_EXCHANGE", allowing external applications to authenticate users by exchanging OAuth provider tokens for Open WebUI session tokens. [Commit](https://github.com/open-webui/open-webui/commit/655420fd25ed0ea872954baa485030079c00c10e)
- 🗄️ **Weaviate custom endpoints.** Administrators can now connect to self-hosted Weaviate deployments with separate HTTP and gRPC endpoints via new environment variables. [#20620](https://github.com/open-webui/open-webui/pull/20620)
- 🛡️ **MCP custom SSL certificates.** Administrators can now connect to MCP servers with self-signed or custom SSL certificates via the "AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL" environment variable. [#20875](https://github.com/open-webui/open-webui/issues/20875), [Commit](https://github.com/open-webui/open-webui/commit/c7f996d593e4bb48103b91316204fe7e50e25b35)
- 🗃️ **Redis Sentinel reconnection delay.** Administrators using Redis Sentinel can now configure a reconnection delay via "REDIS_RECONNECT_DELAY" to prevent retry exhaustion during failover elections. [#21021](https://github.com/open-webui/open-webui/pull/21021)
- 📡 **Custom user info headers.** Administrators can now customize the header names used when forwarding user information to external services, enabling compatibility with services like AWS Bedrock AgentCore that require specific header prefixes. [Commit](https://github.com/open-webui/open-webui/commit/6c0f886cdf4b4249dca29e9340b3b998a7262d61)
- 🔗 **Forward user info to tool servers.** User identity and chat context can now be forwarded to MCP servers and external tool servers when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled, allowing tool providers to implement per-user authorization, auditing, and rate limiting. [#21092](https://github.com/open-webui/open-webui/pull/21092), [Commit](https://github.com/open-webui/open-webui/commit/2c37daef86a058e370151ecead17f10078102307)
- 📬 **External tool event emitters.** External tools (OpenAPI/MCP) can now send tool events back to Open WebUI using the event emitter endpoint, as message ID is now forwarded alongside chat ID when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled. [#21214](https://github.com/open-webui/open-webui/pull/21214)
- 📥 **Playground chat export.** Administrators can now export playground chats as JSON or plain text files, allowing them to save their conversations for backup or sharing outside the platform. [Commit](https://github.com/open-webui/open-webui/commit/8e2b0b6fd2ac99c833a110e2bc6aa655f1682669)
- 🖼️ **Images playground.** Administrators can now test image generation and editing directly in a new Images playground, with support for uploading source images for edits and downloading results. [Commit](https://github.com/open-webui/open-webui/commit/94302de49b27bdf1df86b5c26f2cafb98f964e52)
- 🛠️ **Dynamic dropdown valve fields.** Tool and function developers can now create dropdown fields with dynamically-generated options that update based on runtime context, such as available models or user permissions. [Commit](https://github.com/open-webui/open-webui/commit/474427c67e953bb9f7d122757a756a639214e0b2)
- 🏎️ **Faster profile updates.** User profile updates and role changes are now faster by eliminating redundant database queries. [#21011](https://github.com/open-webui/open-webui/pull/21011)
- 🔑 **Faster authentication.** User authentication is now 34% faster by combining database lookups into a single query. [#21010](https://github.com/open-webui/open-webui/pull/21010)
- 🔋 **Faster chat completions.** Chat completions and embeddings now respond much faster by checking the model cache before fetching model lists, reducing Time To First Token from several seconds to subsecond for most requests. [#20886](https://github.com/open-webui/open-webui/pull/20886), [#20069](https://github.com/open-webui/open-webui/discussions/20069)
- 🏎️ **Faster Redis model list loading.** Model list loading is now significantly faster when using Redis with many models, reducing API response latency by caching configuration values locally instead of making repeated Redis lookups on every model iteration. [#21306](https://github.com/open-webui/open-webui/pull/21306)
- 💨 **Faster knowledge base file batch-add.** Batch-adding files to knowledge bases is now faster with a single database query instead of one query per file. [#21006](https://github.com/open-webui/open-webui/pull/21006)
- ⚡ **Smoother model selector dropdown.** The model selector dropdown now renders smoothly even with hundreds of models, eliminating the lag and freezing that occurred when opening the dropdown with large model lists. [Commit](https://github.com/open-webui/open-webui/commit/4331029926245b7b74fa8e254610c91400b239b0)
- 🚗 **Faster model visibility toggling.** Toggling model visibility in the admin panel is now faster with optimized database access. [#21009](https://github.com/open-webui/open-webui/pull/21009)
- 💾 **Faster model access control checks.** Model access control checks are now faster by batch-fetching model info and group memberships upfront instead of querying for each model. [#21008](https://github.com/open-webui/open-webui/pull/21008)
- ⚙️ **Faster model list and imports.** Model list loading and model imports are now faster by eliminating redundant database queries. [#21004](https://github.com/open-webui/open-webui/pull/21004)
- 🏃 **Faster SCIM group member lookups.** SCIM group member lookups are now up to 13x faster by batching user queries instead of fetching each member individually. [#21005](https://github.com/open-webui/open-webui/pull/21005)
- 💨 **Batched group member counts.** Group member counts are now fetched in a single batch query when loading group lists, eliminating redundant database lookups. [Commit](https://github.com/open-webui/open-webui/commit/96c07f44a8f5e6346b2ea6ac529ff4ec3c47e90a)
- 💨 **Faster bulk operations.** Bulk feedback deletion and group member removal are now 4-5x faster with optimized batch operations. [#21019](https://github.com/open-webui/open-webui/pull/21019)
- 🧠 **Faster memory updates.** Memory updates are now up to 39% faster by eliminating redundant database queries. [#21013](https://github.com/open-webui/open-webui/pull/21013)
- ⚙️ **Faster filter function loading.** Filter function loading is now faster by batching database queries instead of fetching each function individually. [#21018](https://github.com/open-webui/open-webui/pull/21018)
- 🖼️ **Image model regex configuration.** Administrators can now configure which image generation models support auto-sizing and URL responses via new regex environment variables, improving compatibility with LiteLLM and other proxies that use prefixed model names. [#21126](https://github.com/open-webui/open-webui/pull/21126), [Commit](https://github.com/open-webui/open-webui/commit/ecf3fa2feb28e74ff6c17ca97d94581f316da56a)
- 🎁 **Easter eggs toggle.** Administrators can now control the visibility of easter egg features via the "ENABLE_EASTER_EGGS" environment variable. [Commit](https://github.com/open-webui/open-webui/commit/907dba4517903e5646e40223a0edca26a7107bc8)
- 🔌 **Independent access control updates.** API endpoints now support independent access control updates for models, tools, knowledge bases, and notes, enabling finer-grained permission management. [Commit](https://github.com/open-webui/open-webui/commit/0044902c082f8475336cc7d5c57fe3f35ab0555d), [Commit](https://github.com/open-webui/open-webui/commit/c259c878060af1b03b702c943e8813d7b4fc3199), [Commit](https://github.com/open-webui/open-webui/commit/e3a825769063cee486650cc2eb9a032676e630c5)
- ♿ **Screen reader accessibility.** Screen reader users now hear the password field label only once on the login page, improving form navigation for assistive technology users. [Commit](https://github.com/open-webui/open-webui/commit/1441d0d735c7a1470070b33327e1dd4dc5ca1131)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Catalan, Finnish, Irish, French, German, Japanese, Latvian, Polish, Portuguese (Brazil), Simplified Chinese, Slovak, Spanish, and Traditional Chinese were enhanced and expanded.
### Fixed
- ⚡ **Connection pool exhaustion fix.** Database connection pool exhaustion and timeout errors during concurrent usage have been resolved by releasing connections before chat completion requests and embedding operations for memory and knowledge base processing. [#20569](https://github.com/open-webui/open-webui/pull/20569), [#20570](https://github.com/open-webui/open-webui/pull/20570), [#20571](https://github.com/open-webui/open-webui/pull/20571), [#20572](https://github.com/open-webui/open-webui/pull/20572), [#20573](https://github.com/open-webui/open-webui/pull/20573), [#20574](https://github.com/open-webui/open-webui/pull/20574), [#20575](https://github.com/open-webui/open-webui/pull/20575), [#20576](https://github.com/open-webui/open-webui/pull/20576), [#20577](https://github.com/open-webui/open-webui/pull/20577), [#20578](https://github.com/open-webui/open-webui/pull/20578), [#20579](https://github.com/open-webui/open-webui/pull/20579), [#20580](https://github.com/open-webui/open-webui/pull/20580), [#20581](https://github.com/open-webui/open-webui/pull/20581), [Commit](https://github.com/open-webui/open-webui/commit/7da37b4f66b9b2e821796b06b75e03cb0237e0a9), [Commit](https://github.com/open-webui/open-webui/commit/9af40624c5f0f8f7f640a11356e167543b07b2bb)
- 🚫 **LDAP authentication hang fix.** LDAP authentication no longer freezes the entire service when logging in with non-existent accounts, preventing application hangs. [Commit](https://github.com/open-webui/open-webui/commit/a4281f6a7fbc9764b57830e4ef81bb780aa34af9), [#21300](https://github.com/open-webui/open-webui/issues/21300)
- ✅ **Trusted Header auto-registration fix.** Trusted Header Authentication now properly auto-registers new users after the first login, assigning the configured default role instead of failing for users not yet in the database. [Commit](https://github.com/open-webui/open-webui/commit/9b30e8f6894c8c8bad0a9ce4693eab810962adc9)
- 🛡️ **SSRF protection for image loading.** External image loading now validates URLs before fetching to prevent SSRF attacks against local and private network addresses. [Commit](https://github.com/open-webui/open-webui/commit/ce50d9bac4f30b054b09a2fbda52569b73ea591c)
- 🛡️ **Malformed Authorization header fix.** Malformed Authorization headers no longer cause server crashes; requests are now handled gracefully instead of returning HTTP 500 errors. [#20938](https://github.com/open-webui/open-webui/issues/20938), [Commit](https://github.com/open-webui/open-webui/commit/7e79f8d1c6b5a02f1a46e792540c6bbf7bed8edc)
- 🚪 **Channel notification access control.** Users without channel permissions can no longer access channels through notifications, properly enforcing access controls across all channel entry points. [#20883](https://github.com/open-webui/open-webui/pull/20883), [#20789](https://github.com/open-webui/open-webui/discussions/20789)
- 🐛 **Ollama model name suffix fix.** Ollama-compatible providers that do not use ":latest" in model names can now successfully chat, fixing errors where model names were incorrectly appended with ":latest" suffixes. [#21331](https://github.com/open-webui/open-webui/issues/21331), [Commit](https://github.com/open-webui/open-webui/commit/05ae44b98dc279ee12cc8eab17278ccbfec60301)
- ♻️ **Streaming connection cleanup.** Streaming responses now properly clean up network connections when interrupted, preventing "Unclosed client session" errors from accumulating over time. [#20889](https://github.com/open-webui/open-webui/pull/20889), [#17058](https://github.com/open-webui/open-webui/issues/17058)
- 💾 **Inline image context exhaustion fix.** Inline images no longer exhaust the model's context window by including their full base64 data in chat metadata, preventing premature context exhaustion with image-heavy conversations. [#20916](https://github.com/open-webui/open-webui/pull/20916)
- 🚀 **Status indicator GPU usage fix.** High GPU usage caused by the user online status indicator animation has been resolved, reducing consumption from 35-40% to near-zero in browsers with hardware acceleration. [#21062](https://github.com/open-webui/open-webui/issues/21062), [Commit](https://github.com/open-webui/open-webui/commit/938d1b0743c64f0ce513d68e57dfbb86987cb06b)
- 🔧 **Async pipeline operations.** Pipeline operations now run asynchronously instead of blocking the FastAPI event loop, allowing the server to handle other requests while waiting for external pipeline API calls. [#20910](https://github.com/open-webui/open-webui/pull/20910)
- 🔌 **MCP tools regression fix.** MCP tools now work reliably again after a regression in v0.7.2 that caused "cannot pickle '\_asyncio.Future' object" errors when attempting to use MCP servers in chat. [#20629](https://github.com/open-webui/open-webui/issues/20629), [#20500](https://github.com/open-webui/open-webui/issues/20500), [Commit](https://github.com/open-webui/open-webui/commit/886c12c5664bc2dd73313330f61c2257169da6d1)
- 🔗 **Function chat ID propagation fix.** Functions now reliably receive the chat identifier during internal task invocations like web search query generation, RAG query generation, and image prompt generation, enabling stateful functions to maintain consistent per-chat state without fragmentation. [#20563](https://github.com/open-webui/open-webui/issues/20563), [#20585](https://github.com/open-webui/open-webui/pull/20585)
- 💻 **Markdown fence code execution fix.** Code execution now works reliably when models wrap code in markdown fences, automatically stripping the backticks before execution to prevent syntax errors that affected most non-GPT models. [#20941](https://github.com/open-webui/open-webui/issues/20941), [Commit](https://github.com/open-webui/open-webui/commit/4a5516775927aaf002212f2e09c55a17c699bc46), [Commit](https://github.com/open-webui/open-webui/commit/683438b418fb3b453a8ad88c1ba1a9944eac3593)
- 💻 **ANSI code execution fix.** Code execution is now reliable when LLMs include ANSI terminal color codes in their output, preventing random failures that previously caused syntax errors. [#21091](https://github.com/open-webui/open-webui/issues/21091), [Commit](https://github.com/open-webui/open-webui/commit/b1737040a7d3bb5efcfe0f1432e89d7e82e51d2d)
- 🗨️ **Incomplete model metadata crash fix.** Starting chats with models that have incomplete metadata information no longer crashes the application. [#20565](https://github.com/open-webui/open-webui/issues/20565), [Commit](https://github.com/open-webui/open-webui/commit/14f6747dfc66fb7e942b930650286012121e5262)
- 💬 **Unavailable model crash fix.** Adding message pairs with Ctrl+Shift+Enter no longer crashes when the chat's model is unavailable, showing a helpful error message instead. [#20663](https://github.com/open-webui/open-webui/pull/20663)
- 📚 **Knowledge base file upload fix.** Uploading files to knowledge bases now works correctly, fixing database mapping errors that prevented file uploads. [#20925](https://github.com/open-webui/open-webui/issues/20925), [#20931](https://github.com/open-webui/open-webui/pull/20931)
- 🧠 **Knowledge base query type fix.** Knowledge base queries no longer fail intermittently when models send tool call parameters as strings instead of their expected types. [#20705](https://github.com/open-webui/open-webui/pull/20705)
- 📚 **Knowledge base reindex fix.** Reindexing knowledge base files now works correctly instead of failing with duplicate content errors. [#20854](https://github.com/open-webui/open-webui/issues/20854), [#20857](https://github.com/open-webui/open-webui/pull/20857)
- 🔧 **Multi-worker knowledge base timeout fix.** In multi-worker deployments, uploading very large documents to knowledge bases no longer causes workers to be killed by health check timeouts, and administrators can now configure a custom embedding timeout via "RAG_EMBEDDING_TIMEOUT". [#21158](https://github.com/open-webui/open-webui/pull/21158), [Discussion](https://github.com/open-webui/open-webui/discussions/21151), [Commit](https://github.com/open-webui/open-webui/commit/c653e4ec54d070aee5e9568d016daebb61f06632)
- 🌅 **Dark mode icon inversion fix.** Icons in chat and action menus are now displayed correctly in dark mode, fixing an issue where PNG icons with "svg" in their base64 encoding were randomly inverted. [#21272](https://github.com/open-webui/open-webui/pull/21272), [Commit](https://github.com/open-webui/open-webui/commit/0a44d80252afae73de4098ab1c3eb6cf54157fd6)
- 🛠️ **Admin model write permission fix.** Fixed the admin panel allowing models to be assigned write permissions, since users with write permission are not admins and cannot write. [Commit](https://github.com/open-webui/open-webui/commit/4aedfdc5471a1f13c1084b34b48ea3ed6311cd42)
- 🛠️ **Prompt access control save fix.** Prompt access control settings are now saved correctly when modifying resource permissions. [Commit](https://github.com/open-webui/open-webui/commit/30f72672fac2579c267a076e6ba89dfe1812137b)
- ✏️ **Knowledge base file edit fix.** Editing files within knowledge bases now saves correctly and can be used for retrieval, fixing a silent failure where the save appeared successful but the file could not be searched. [Commit](https://github.com/open-webui/open-webui/commit/f9ab66f51a52388a4eb084c8f69044e79bf5cb04)
- 🖼️ **Reasoning section artifact rendering fix.** Code blocks within model reasoning sections no longer incorrectly render as interactive artifacts, ensuring only intended output displays as previews. [#20801](https://github.com/open-webui/open-webui/issues/20801), [#20877](https://github.com/open-webui/open-webui/pull/20877), [Commit](https://github.com/open-webui/open-webui/commit/4c6f100b5fe2145a3d676b70b5f7c0e7f07cee20)
- 🔐 **Group resource sharing fix.** Sharing resources with groups now works correctly, fixing database errors and an issue where models shared with read-only access were not visible to group members. [#20666](https://github.com/open-webui/open-webui/issues/20666), [#21043](https://github.com/open-webui/open-webui/issues/21043), [Commit](https://github.com/open-webui/open-webui/commit/5a075a2c836e46b83f8710285f09aff1f6125072)
- 🔑 **Docling API key fix.** Docling API key authentication now works correctly by using the proper "X-Api-Key" header format instead of the incorrect "Bearer" authorization prefix. [#20652](https://github.com/open-webui/open-webui/pull/20652)
- 🔌 **MCP OAuth 2.1 fix.** MCP OAuth 2.1 authentication now works correctly, resolving connection verification failures and 401 errors during the authorization callback. [#20808](https://github.com/open-webui/open-webui/issues/20808), [#20828](https://github.com/open-webui/open-webui/issues/20828), [Commit](https://github.com/open-webui/open-webui/commit/8eebc2aea63b7045e61c9689a65a2dfa9c797bcb)
- 💻 **MATLAB syntax highlighting.** MATLAB code blocks now display with proper syntax highlighting in chat messages. [#20719](https://github.com/open-webui/open-webui/issues/20719), [#20773](https://github.com/open-webui/open-webui/pull/20773)
- 📊 **CSV export HTML entity decoding.** Exporting tables to CSV now properly decodes HTML entities, ensuring special characters display correctly in the exported file. [#20688](https://github.com/open-webui/open-webui/pull/20688)
- 📄 **Markdown Header Text Splitter persistence.** The "Markdown Header Text Splitter" document setting now persists correctly when disabled, preventing it from reverting to enabled after page refresh. [#20929](https://github.com/open-webui/open-webui/issues/20929), [#20930](https://github.com/open-webui/open-webui/pull/20930)
- 🔌 **Audio service timeout handling.** Audio transcription and text-to-speech requests now have proper timeouts, preventing the UI from freezing when external services don't respond. [#21055](https://github.com/open-webui/open-webui/pull/21055)
- 💬 **Reference Chats visibility fix.** The "Reference Chats" option now appears in the message input menu even when the sidebar is collapsed, fixing the issue where it was hidden on mobile devices and at first load. [#20827](https://github.com/open-webui/open-webui/issues/20827), [Commit](https://github.com/open-webui/open-webui/commit/a3600e8b219fc4c019b95258d16bd3e2827490c6)
- 🔍 **Chat search self-exclusion.** The "search_chats" builtin tool now excludes the current conversation from search results, preventing redundant matches. [#20718](https://github.com/open-webui/open-webui/issues/20718), [Commit](https://github.com/open-webui/open-webui/commit/1a4bdd2b30017d901b9cac1e2e10684ec1edd062)
- 📚 **Knowledge base pagination fix.** Paginating through knowledge base files no longer shows duplicates or skips files when multiple documents share the same update timestamp. [#20846](https://github.com/open-webui/open-webui/issues/20846), [Commit](https://github.com/open-webui/open-webui/commit/a9a0ce6beaa286cc18eff24b518a6f3d7a560e2f)
- 📋 **Batch file error reporting.** Batch file processing operations now return properly structured error information when failures occur, making it clearer what went wrong during multi-file operations. [#20795](https://github.com/open-webui/open-webui/issues/20795), [Commit](https://github.com/open-webui/open-webui/commit/68b2872ed645cffb641fa5a21a784d6e9ea0d72b)
- ⚙️ **Persistent config with Redis fix.** Configuration values now respect the "ENABLE_PERSISTENT_CONFIG" setting when Redis is used, ensuring environment variables are reloaded on restart when persistent config is disabled. [#20830](https://github.com/open-webui/open-webui/issues/20830), [Commit](https://github.com/open-webui/open-webui/commit/5d48e48e15b003874cc821d896998a01e87580a0)
- 🔧 **Engine.IO logging fix.** The "WEBSOCKET_SERVER_ENGINEIO_LOGGING" environment variable now works correctly, allowing administrators to configure Engine.IO logging independently from general websocket logging. [#20727](https://github.com/open-webui/open-webui/pull/20727), [Commit](https://github.com/open-webui/open-webui/commit/5cfb7a08cbde5d39aaf4097b849a80da87c30d66)
- 🌐 **French language default fix.** Browsers requesting French language now default to French (France) instead of French (Canada), matching standard language preference expectations. [#20603](https://github.com/open-webui/open-webui/pull/20603), [Commit](https://github.com/open-webui/open-webui/commit/4d9a7cc6c0adea54b58046c576250a0c3ae7b512)
- 🔘 **Firefox delete button fix.** Pressing Enter after clicking delete buttons no longer incorrectly retriggers confirmation modals in Firefox. [Commit](https://github.com/open-webui/open-webui/commit/57a2024c58b9c674f2ae08eeb552994ef1796888)
- 🌍 **RTL table rendering fix.** Chat markdown tables now correctly display right-to-left when containing RTL language content (Arabic, Hebrew, Farsi, etc.), matching the "Auto" direction setting behavior. [#21160](https://github.com/open-webui/open-webui/issues/21160), [Commit](https://github.com/open-webui/open-webui/commit/284b97bd84c824013ad00ea07621192ec69a5e93)
- 🔒 **Write permission enforcement for tools.** Users without write permissions are now properly prevented from editing tools, with a clear error message displayed when attempting unauthorized edits. [Commit](https://github.com/open-webui/open-webui/commit/85e92fe3b062ae669985c09495f6ff1baf8176ab), [Commit](https://github.com/open-webui/open-webui/commit/91faa9fd5a1cfc5d3ab531d2d91d28db52bcc702)
- 🛡️ **Chat Valves permission enforcement.** The "Allow Chat Valves" permission is now properly enforced in the integrations menu, preventing users from bypassing access restrictions. [#20691](https://github.com/open-webui/open-webui/pull/20691)
- 📝 **Audit log browser session fix.** Audit logs now properly capture all user activity including browser-based sessions, not just API key requests. [#20651](https://github.com/open-webui/open-webui/issues/20651), [Commit](https://github.com/open-webui/open-webui/commit/86e6b2b68b85e958188881785495030de1a30402), [Commit](https://github.com/open-webui/open-webui/commit/ee5fd1246cb3f8f16ca5cbb24feeea43b7800dcb)
- 🎨 **Long model name truncation.** Long model names and IDs in the admin panel now truncate properly to prevent visual overflow, with full names visible on hover. [#20696](https://github.com/open-webui/open-webui/pull/20696)
- 👥 **Admin user filter pagination fix.** Filtering users in the admin panel now automatically resets to page 1, preventing empty results when searching from pages beyond the first. [#20723](https://github.com/open-webui/open-webui/pull/20723), [Commit](https://github.com/open-webui/open-webui/commit/be75bc506adb048ef11b1612c0e3662511c920d0)
- 🔎 **Username search on workspace pages.** Searching for users by username now works correctly on Models, Knowledge, and Functions workspace pages, making it easier to find resources owned by specific users. [#20780](https://github.com/open-webui/open-webui/pull/20780)
- 🗑️ **File deletion orphaned embeddings fix.** Deleting files now properly removes associated knowledge base embeddings, preventing orphaned data from accumulating. [Commit](https://github.com/open-webui/open-webui/commit/93ed4ae2cda2f4311143e51f586aaa73b83a37a7)
- 🧹 **Event listener memory leak fix.** Memory leaks caused by event listeners not being cleaned up during navigation have been resolved. [#20913](https://github.com/open-webui/open-webui/pull/20913)
- 🐳 **Docker Ollama update fix.** Ollama can now be updated within Docker containers after adding a missing zstd dependency. [#20994](https://github.com/open-webui/open-webui/issues/20994), [#21052](https://github.com/open-webui/open-webui/pull/21052)
- 📝 **Workspace duplicate API request fix.** The prompts, knowledge, and models workspaces no longer make duplicate API requests when loading. [Commit](https://github.com/open-webui/open-webui/commit/ab5dfbda54664c9278b0d807ba06cad94edd798f), [Commit](https://github.com/open-webui/open-webui/commit/e5dbfc420dd3e7f6ba047a3e11584449ff0742b4)
- 📡 **OpenTelemetry Redis cluster fix.** OpenTelemetry instrumentation now works correctly with Redis cluster mode deployments. [#21129](https://github.com/open-webui/open-webui/pull/21129)
- 🐳 **Airgapped NLTK tokenizer fix.** Document extraction now works reliably in airgapped environments after container restarts by bundling NLTK tokenizer data in the Docker image. [#21165](https://github.com/open-webui/open-webui/pull/21165), [#21150](https://github.com/open-webui/open-webui/issues/21150)
- 💬 **Channel model mention crash fix.** Mentioning a model in channels no longer crashes when older thread messages have missing data. [#21112](https://github.com/open-webui/open-webui/pull/21112)
- 🔧 **OpenAPI tool import fix.** Importing OpenAPI tool specifications no longer crashes when parameters lack explicit name fields, fixing compatibility with complex request body definitions. [#21121](https://github.com/open-webui/open-webui/pull/21121), [Commit](https://github.com/open-webui/open-webui/commit/8e79b3d0bc4903f30e747b663ac818976618c83c)
- 🌐 **Webpage attachment content fix.** Attaching webpages to chats now retrieves full content instead of only metadata, fixing an unawaited coroutine in SSL certificate verification. [#21166](https://github.com/open-webui/open-webui/issues/21166), [Commit](https://github.com/open-webui/open-webui/commit/a214ec40ea00eebcba49570647ca6ab8f61765d5)
- 💾 **File upload settings persistence.** File upload settings (Max Upload Size, Max File Count, Image Compression dimensions) now persist correctly and are no longer erased when updating other RAG configuration settings. [#21057](https://github.com/open-webui/open-webui/issues/21057), [Commit](https://github.com/open-webui/open-webui/commit/258454276e1ef8ded24968515f7bf5e1833ca011)
- 📦 **Tool call expand/collapse fix.** Tool call results in chat can now be expanded and collapsed again after a recent refactor disabled this behavior. [#21205](https://github.com/open-webui/open-webui/pull/21205)
- 🪛 **Disabled API endpoint bypass fix.** Fixed Ollama/OpenAI API endpoints bypassing 'ENABLE_OLLAMA_API' and 'ENABLE_OPENAI_API' flags when the 'url_idx' parameter was provided. Endpoints now properly return a 503 error with a clear "API is disabled" message instead of attempting to connect and logging confusing connection errors.
- 🛠️ **OpenSearch 3.0 compatibility fix.** Document uploads to knowledge bases now work correctly when using OpenSearch backend with opensearch-py >= 3.0.0, fixing a TypeError that previously caused failures. [#21248](https://github.com/open-webui/open-webui/pull/21248), [#20649](https://github.com/open-webui/open-webui/issues/20649)
- 📱 **Gboard multi-line paste fix.** Multi-line text pasted from Gboard on Android now inserts correctly instead of being replaced with a single newline, fixing a bug where the keyboard's clipboard suggestion strip sent text via 'insertText' events instead of standard paste events. [#21265](https://github.com/open-webui/open-webui/pull/21265)
- 🔧 **Batch embeddings endpoint fix.** The '/api/embeddings' endpoint now correctly returns separate embeddings for each input string when processing batch requests to Ollama providers. [Commit](https://github.com/open-webui/open-webui/commit/8fd5c06e5bf7e0ccbda15d83338912ea17f66783), [#21279](https://github.com/open-webui/open-webui/issues/21279)
- 🗝️ **SSL verification for embeddings.** SSL certificate verification now respects the "AIOHTTP_CLIENT_SESSION_SSL" setting for OpenAI and Azure OpenAI embedding requests, allowing connections to self-signed certificate endpoints when disabled. [Commit](https://github.com/open-webui/open-webui/commit/cd31b8301b38bfa86872608cfbd022ff74e3ae52)
- 🔧 **Tool call HTML entity fix.** Models now receive properly formatted tool call results in multi-turn conversations, fixing an issue where HTML entities caused malformed content that was hard to parse. [#20755](https://github.com/open-webui/open-webui/pull/20755)
- 💾 **Duplicate inline image context fix.** Inline images no longer exhaust the model's context window by including their full base64 data in chat metadata, preventing premature context exhaustion with image-heavy conversations. [#20916](https://github.com/open-webui/open-webui/pull/20916)
- 🐛 **OpenAI model cache lookup fix.** The OpenAI API router model lookup was corrected to use the proper model identifier when checking the cache, ensuring consistent and correct model retrieval during chat completions. [#21327](https://github.com/open-webui/open-webui/pull/21327)
- 🐛 **Ollama latest suffix fix.** Ollama-compatible providers that don't use ":latest" in model names can now successfully chat, fixing errors where model names were incorrectly appended with ":latest" suffixes. [#21331](https://github.com/open-webui/open-webui/issues/21331), [Commit](https://github.com/open-webui/open-webui/commit/05ae44b98dc279ee12cc8eab17278ccbfec60301)
- ⛔ **OpenAI endpoint detection fix.** OpenAI API endpoint detection was corrected to use exact hostname matching instead of substring matching, preventing third-party providers with similar URL patterns from being incorrectly filtered. [Commit](https://github.com/open-webui/open-webui/commit/423d8b18170a0b92b582aba6ef7bb9ba173e876e)
- 🛠️ **RedisCluster task stopping fix.** Task stopping now works correctly in RedisCluster deployments, fixing an issue where tasks would remain active after cancellation attempts. [#20803](https://github.com/open-webui/open-webui/pull/20803), [Commit](https://github.com/open-webui/open-webui/commit/0dcbd05e2436929ae9d2c559a204844ae0239b57)
- 📎 **Citation parsing error fix.** Citation parsing no longer crashes when builtin tools return error responses, fixing AttributeError issues when tools like search_web fail. [#21071](https://github.com/open-webui/open-webui/pull/21071)
### Changed
- ‼️ **Database Migration Required** — This release includes database schema changes; multi-worker, multi-server, or load-balanced deployments must update all instances simultaneously rather than performing rolling updates, as running mixed versions will cause application failures due to schema incompatibility between old and new instances.
- ⚠️ **Chat Message Table Migration** — This release includes a new chat message table migration that can take a significant amount of time to complete in larger deployments with extensive chat histories. Administrators should plan for adequate maintenance windows and allow the migration to complete fully without interruption. Running the migration with insufficient time or resources may result in data integrity issues.
- 🔗 **Prompt ID-based URLs.** Prompts now use unique ID-based URLs instead of command-based URLs, allowing more flexible command renaming without breaking saved links or integrations. [#20945](https://github.com/open-webui/open-webui/pull/20945)
## [0.7.2] - 2026-01-10
### Fixed
- ⚡ Users no longer experience database connection timeouts under high concurrency due to connections being held during LLM calls, telemetry collection, and file status streaming. [#20545](https://github.com/open-webui/open-webui/pull/20545), [#20542](https://github.com/open-webui/open-webui/pull/20542), [#20547](https://github.com/open-webui/open-webui/pull/20547)
- 📝 Users can now create and save prompts in the workspace prompts editor without encountering errors. [Commit](https://github.com/open-webui/open-webui/commit/ab99d3b1129cffbc13cf7de5aa897692e3f8662e)
- 🎙️ Users can now use local Whisper for speech-to-text when STT_ENGINE is left empty (the default for local mode). [#20534](https://github.com/open-webui/open-webui/pull/20534)
- 📊 The Evaluations page now loads faster by eliminating duplicate API calls to the leaderboard and feedbacks endpoints. [Commit](https://github.com/open-webui/open-webui/commit/2dd09223f2aac301a4d5c17fb667d974c34f3ff1)
- 🌐 Fixed missing Settings tab i18n label keys. [#20526](https://github.com/open-webui/open-webui/pull/20526)
## [0.7.1] - 2026-01-09
### Fixed
- ⚡ **Improved reliability for low-spec and SQLite deployments.** Fixed page timeouts by disabling database session sharing by default, improving stability for resource-constrained environments. Users can re-enable via 'DATABASE_ENABLE_SESSION_SHARING=true' if needed. [#20520](https://github.com/open-webui/open-webui/issues/20520)
## [0.7.0] - 2026-01-09
### Added
+6 -3
View File
@@ -128,7 +128,7 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \
git build-essential pandoc gcc netcat-openbsd curl jq \
python3-dev \
ffmpeg libsm6 libxext6 \
ffmpeg libsm6 libxext6 zstd \
&& rm -rf /var/lib/apt/lists/*
# install python dependencies
@@ -137,20 +137,23 @@ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
RUN pip3 install --no-cache-dir uv && \
if [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
# fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
else \
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \
if [ "$USE_SLIM" != "true" ]; then \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
fi; \
fi; \
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
+98 -3
View File
@@ -2,7 +2,9 @@ import json
import logging
import os
import shutil
import socket
import base64
from concurrent.futures import ThreadPoolExecutor
import redis
from datetime import datetime
@@ -257,7 +259,7 @@ class AppConfig:
self._state[key].value = value
self._state[key].save()
if self._redis:
if self._redis and ENABLE_PERSISTENT_CONFIG:
redis_key = f"{self._redis_key_prefix}:config:{key}"
self._redis.set(redis_key, json.dumps(self._state[key].value))
@@ -265,8 +267,8 @@ class AppConfig:
if key not in self._state:
raise AttributeError(f"Config key '{key}' not found")
# If Redis is available, check for an updated value
if self._redis:
# If Redis is available and persistent config is enabled, check for an updated value
if self._redis and ENABLE_PERSISTENT_CONFIG:
redis_key = f"{self._redis_key_prefix}:config:{key}"
redis_value = self._redis.get(redis_key)
@@ -1015,6 +1017,39 @@ if ENV == "prod":
OLLAMA_BASE_URL = "http://ollama-service.open-webui.svc.cluster.local:11434"
def _resolve_ollama_base_url(url: str) -> str:
"""If the default Ollama port (11434) is unreachable, try the fallback port (12434)."""
def reachable(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=1.0):
return True
except (OSError, TimeoutError):
return False
host = urlparse(url).hostname or "localhost"
with ThreadPoolExecutor(max_workers=2) as pool:
default = pool.submit(reachable, host, 11434)
fallback = pool.submit(reachable, host, 12434)
if not default.result() and fallback.result():
url = url.replace(":11434", ":12434")
log.info(f"Ollama port 11434 unreachable on {host}, falling back to 12434")
elif not default.result():
log.info(f"Ollama ports 11434 and 12434 both unreachable on {host}")
return url
# Auto-resolve Ollama port when no explicit URL was provided by the user.
# The Dockerfile default is "/ollama" which the block above rewrites to :11434.
if os.environ.get("OLLAMA_BASE_URL", "") in ("", "/ollama") and not os.environ.get(
"OLLAMA_BASE_URLS", ""
):
OLLAMA_BASE_URL = _resolve_ollama_base_url(OLLAMA_BASE_URL)
OLLAMA_BASE_URLS = os.environ.get("OLLAMA_BASE_URLS", "")
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != "" else OLLAMA_BASE_URL
@@ -1265,6 +1300,11 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
)
USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT", "False").lower()
== "true"
@@ -1345,6 +1385,18 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
== "true"
)
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower() == "true"
@@ -1486,6 +1538,7 @@ DEFAULT_USER_PERMISSIONS = {
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
"skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS,
"models_import": USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT,
"models_export": USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT,
"prompts_import": USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT,
@@ -1502,6 +1555,8 @@ DEFAULT_USER_PERMISSIONS = {
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
"skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING,
"public_skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING,
"notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
@@ -2246,9 +2301,15 @@ ENABLE_QDRANT_MULTITENANCY_MODE = (
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
WEAVIATE_HTTP_HOST = os.environ.get("WEAVIATE_HTTP_HOST", "")
WEAVIATE_GRPC_HOST = os.environ.get("WEAVIATE_GRPC_HOST", "")
WEAVIATE_HTTP_PORT = int(os.environ.get("WEAVIATE_HTTP_PORT", "8080"))
WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051"))
WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY")
WEAVIATE_HTTP_SECURE = os.environ.get("WEAVIATE_HTTP_SECURE", "false").lower() == "true"
WEAVIATE_GRPC_SECURE = os.environ.get("WEAVIATE_GRPC_SECURE", "false").lower() == "true"
WEAVIATE_SKIP_INIT_CHECKS = (
os.environ.get("WEAVIATE_SKIP_INIT_CHECKS", "false").lower() == "true"
)
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
@@ -2805,6 +2866,12 @@ PDF_EXTRACT_IMAGES = PersistentConfig(
os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true",
)
PDF_LOADER_MODE = PersistentConfig(
"PDF_LOADER_MODE",
"rag.pdf_loader_mode",
os.environ.get("PDF_LOADER_MODE", "page"),
)
RAG_EMBEDDING_MODEL = PersistentConfig(
"RAG_EMBEDDING_MODEL",
"rag.embedding_model",
@@ -3400,6 +3467,24 @@ EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig(
os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""),
)
YANDEX_WEB_SEARCH_URL = PersistentConfig(
"YANDEX_WEB_SEARCH_URL",
"rag.web.search.yandex_web_search_url",
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
)
YANDEX_WEB_SEARCH_API_KEY = PersistentConfig(
"YANDEX_WEB_SEARCH_API_KEY",
"rag.web.search.yandex_web_search_api_key",
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
)
YANDEX_WEB_SEARCH_CONFIG = PersistentConfig(
"YANDEX_WEB_SEARCH_CONFIG",
"rag.web.search.yandex_web_search_config",
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""),
)
####################################
# Images
####################################
@@ -3422,6 +3507,16 @@ IMAGE_GENERATION_MODEL = PersistentConfig(
os.getenv("IMAGE_GENERATION_MODEL", ""),
)
# Regex pattern for models that support IMAGE_SIZE = "auto".
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv(
"IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN", "^gpt-image"
)
# Regex pattern for models that return URLs instead of base64 data.
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv(
"IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN", "^gpt-image"
)
IMAGE_SIZE = PersistentConfig(
"IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512")
)
+76 -8
View File
@@ -194,11 +194,35 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
)
# Header names for user info forwarding (customizable via environment variables)
FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get(
"FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name"
)
FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get(
"FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id"
)
FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get(
"FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email"
)
FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get(
"FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role"
)
# Header name for chat ID forwarding (customizable via environment variable)
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID = os.environ.get(
"FORWARD_SESSION_INFO_HEADER_MESSAGE_ID", "X-OpenWebUI-Message-Id"
)
FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get(
"FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id"
)
# Experimental feature, may be removed in future
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
)
ENABLE_EASTER_EGGS = os.environ.get("ENABLE_EASTER_EGGS", "True").lower() == "true"
####################################
# WEBUI_BUILD_HASH
####################################
@@ -341,6 +365,11 @@ if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
except Exception:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
# When enabled, get_db_context reuses existing sessions; set to False to always create new sessions
DATABASE_ENABLE_SESSION_SHARING = (
os.environ.get("DATABASE_ENABLE_SESSION_SHARING", "False").lower() == "true"
)
# Enable public visibility of active user count (when disabled, only admins can see it)
ENABLE_PUBLIC_ACTIVE_USERS_COUNT = (
os.environ.get("ENABLE_PUBLIC_ACTIVE_USERS_COUNT", "True").lower() == "true"
@@ -386,6 +415,18 @@ try:
except ValueError:
REDIS_SOCKET_CONNECT_TIMEOUT = None
REDIS_RECONNECT_DELAY = os.environ.get("REDIS_RECONNECT_DELAY", "")
if REDIS_RECONNECT_DELAY == "":
REDIS_RECONNECT_DELAY = None
else:
try:
REDIS_RECONNECT_DELAY = float(REDIS_RECONNECT_DELAY)
if REDIS_RECONNECT_DELAY < 0:
REDIS_RECONNECT_DELAY = None
except Exception:
REDIS_RECONNECT_DELAY = None
####################################
# UVICORN WORKERS
####################################
@@ -437,7 +478,7 @@ ENABLE_PASSWORD_VALIDATION = (
)
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
"PASSWORD_VALIDATION_REGEX_PATTERN",
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
)
@@ -450,6 +491,8 @@ except Exception as e:
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
)
PASSWORD_VALIDATION_HINT = os.environ.get("PASSWORD_VALIDATION_HINT", "")
BYPASS_MODEL_ACCESS_CONTROL = (
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
@@ -514,6 +557,12 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
# Token Exchange Configuration
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = (
os.environ.get("ENABLE_OAUTH_TOKEN_EXCHANGE", "False").lower() == "true"
)
####################################
# SCIM Configuration
####################################
@@ -523,6 +572,14 @@ ENABLE_SCIM = (
== "true"
)
SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "")
SCIM_AUTH_PROVIDER = os.environ.get("SCIM_AUTH_PROVIDER", "")
if ENABLE_SCIM and not SCIM_AUTH_PROVIDER:
log.warning(
"SCIM is enabled but SCIM_AUTH_PROVIDER is not set. "
"Set SCIM_AUTH_PROVIDER to the OAuth provider name (e.g. 'microsoft', 'oidc') "
"to enable externalId storage."
)
####################################
# LICENSE_KEY
@@ -540,15 +597,11 @@ LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "")
pk = None
if LICENSE_PUBLIC_KEY:
pk = serialization.load_pem_public_key(
f"""
pk = serialization.load_pem_public_key(f"""
-----BEGIN PUBLIC KEY-----
{LICENSE_PUBLIC_KEY}
-----END PUBLIC KEY-----
""".encode(
"utf-8"
)
)
""".encode("utf-8"))
####################################
@@ -668,7 +721,11 @@ WEBSOCKET_SERVER_LOGGING = (
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
)
WEBSOCKET_SERVER_ENGINEIO_LOGGING = (
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
os.environ.get(
"WEBSOCKET_SERVER_ENGINEIO_LOGGING",
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False"),
).lower()
== "true"
)
WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get("WEBSOCKET_SERVER_PING_TIMEOUT", "20")
try:
@@ -734,6 +791,17 @@ AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
)
RAG_EMBEDDING_TIMEOUT = os.environ.get("RAG_EMBEDDING_TIMEOUT", "")
if RAG_EMBEDDING_TIMEOUT == "":
RAG_EMBEDDING_TIMEOUT = None
else:
try:
RAG_EMBEDDING_TIMEOUT = int(RAG_EMBEDDING_TIMEOUT)
except Exception:
RAG_EMBEDDING_TIMEOUT = None
####################################
# SENTENCE TRANSFORMERS
####################################
-2
View File
@@ -35,7 +35,6 @@ from open_webui.utils.plugin import (
get_function_module_from_cache,
)
from open_webui.utils.tools import get_tools
from open_webui.utils.access_control import has_access
from open_webui.env import GLOBAL_LOG_LEVEL
@@ -51,7 +50,6 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
+2 -1
View File
@@ -14,6 +14,7 @@ from open_webui.env import (
DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
ENABLE_DB_MIGRATIONS,
)
from peewee_migrate import Router
@@ -164,7 +165,7 @@ get_db = contextmanager(get_session)
@contextmanager
def get_db_context(db: Optional[Session] = None):
if isinstance(db, Session):
if isinstance(db, Session) and DATABASE_ENABLE_SESSION_SHARING:
yield db
else:
with get_db() as session:
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -25,7 +25,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
+103 -12
View File
@@ -64,10 +64,12 @@ from open_webui.socket.main import (
MODELS,
app as socket_app,
periodic_usage_pool_cleanup,
periodic_session_pool_cleanup,
get_event_emitter,
get_models_in_use,
)
from open_webui.routers import (
analytics,
audio,
images,
ollama,
@@ -89,6 +91,7 @@ from open_webui.routers import (
knowledge,
prompts,
evaluations,
skills,
tools,
users,
utils,
@@ -288,6 +291,7 @@ from open_webui.config import (
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE,
YOUTUBE_LOADER_LANGUAGE,
YOUTUBE_LOADER_PROXY_URL,
# Retrieval (Web Search)
@@ -352,6 +356,9 @@ from open_webui.config import (
EXTERNAL_WEB_SEARCH_API_KEY,
EXTERNAL_WEB_LOADER_URL,
EXTERNAL_WEB_LOADER_API_KEY,
YANDEX_WEB_SEARCH_URL,
YANDEX_WEB_SEARCH_API_KEY,
YANDEX_WEB_SEARCH_CONFIG,
# WebUI
WEBUI_AUTH,
WEBUI_NAME,
@@ -490,6 +497,7 @@ from open_webui.env import (
WEBUI_ADMIN_EMAIL,
WEBUI_ADMIN_PASSWORD,
WEBUI_ADMIN_NAME,
ENABLE_EASTER_EGGS,
)
@@ -502,11 +510,15 @@ from open_webui.utils.models import (
from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler,
chat_completed as chat_completed_handler,
chat_action as chat_action_handler,
)
from open_webui.utils.actions import chat_action as chat_action_handler
from open_webui.utils.embeddings import generate_embeddings
from open_webui.utils.middleware import process_chat_payload, process_chat_response
from open_webui.utils.access_control import has_access
from open_webui.utils.middleware import (
build_chat_response_context,
process_chat_payload,
process_chat_response,
)
from open_webui.utils.tools import set_tool_servers
from open_webui.utils.auth import (
get_license_data,
@@ -541,7 +553,6 @@ from open_webui.utils.redis import get_sentinels_from_env
from open_webui.constants import ERROR_MESSAGES
if SAFE_MODE:
print("SAFE MODE ENABLED")
Functions.deactivate_all_functions()
@@ -565,8 +576,7 @@ class SPAStaticFiles(StaticFiles):
raise ex
print(
rf"""
print(rf"""
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
@@ -578,12 +588,15 @@ print(
v{VERSION} - building the best AI user interface.
{f"Commit: {WEBUI_BUILD_HASH}" if WEBUI_BUILD_HASH != "dev-build" else ""}
https://github.com/open-webui/open-webui
"""
)
""")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Store reference to main event loop for sync->async calls (e.g., embedding generation)
# This allows sync functions to schedule work on the main loop without blocking health checks
app.state.main_loop = asyncio.get_running_loop()
app.state.instance_id = INSTANCE_ID
start_logger()
@@ -623,6 +636,7 @@ async def lifespan(app: FastAPI):
limiter.total_tokens = THREAD_POOL_SIZE
asyncio.create_task(periodic_usage_pool_cleanup())
asyncio.create_task(periodic_session_pool_cleanup())
if app.state.config.ENABLE_BASE_MODELS_CACHE:
await get_all_models(
@@ -645,6 +659,30 @@ async def lifespan(app: FastAPI):
None,
)
# Pre-fetch tool server specs so the first request doesn't pay the latency cost
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
log.info("Initializing tool servers...")
try:
mock_request = Request(
{
"type": "http",
"asgi.version": "3.0",
"asgi.spec_version": "2.0",
"method": "GET",
"path": "/internal",
"query_string": b"",
"headers": Headers({}).raw,
"client": ("127.0.0.1", 12345),
"server": ("127.0.0.1", 80),
"scheme": "http",
"app": app,
}
)
await set_tool_servers(mock_request)
log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)")
except Exception as e:
log.warning(f"Failed to initialize tool servers at startup: {e}")
yield
if hasattr(app.state, "redis_task_command_listener"):
@@ -806,6 +844,21 @@ app.state.config.ENABLE_USER_STATUS = ENABLE_USER_STATUS
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
# Migrate legacy access_control → access_grants on boot
from open_webui.utils.access_control import migrate_access_control
connections = app.state.config.TOOL_SERVER_CONNECTIONS
if any("access_control" in c.get("config", {}) for c in connections):
for connection in connections:
migrate_access_control(connection.get("config", {}))
app.state.config.TOOL_SERVER_CONNECTIONS = connections
arena_models = app.state.config.EVALUATION_ARENA_MODELS
if any("access_control" in m.get("meta", {}) for m in arena_models):
for model in arena_models:
migrate_access_control(model.get("meta", {}))
app.state.config.EVALUATION_ARENA_MODELS = arena_models
app.state.config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
app.state.config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
app.state.config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
@@ -944,6 +997,7 @@ app.state.config.RAG_OLLAMA_BASE_URL = RAG_OLLAMA_BASE_URL
app.state.config.RAG_OLLAMA_API_KEY = RAG_OLLAMA_API_KEY
app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES
app.state.config.PDF_LOADER_MODE = PDF_LOADER_MODE
app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE
app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL
@@ -1005,6 +1059,9 @@ app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL
app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY
app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL
app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL
app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY
app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
@@ -1315,7 +1372,9 @@ class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
token = None
if auth_header:
scheme, token = auth_header.split(" ")
parts = auth_header.split(" ", 1)
if len(parts) == 2:
token = parts[1]
# Only apply restrictions if an sk- API key is used
if token and token.startswith("sk-"):
@@ -1356,7 +1415,13 @@ app.add_middleware(APIKeyRestrictionMiddleware)
async def commit_session_after_request(request: Request, call_next):
response = await call_next(request)
# log.debug("Commit session after request")
ScopedSession.commit()
try:
ScopedSession.commit()
finally:
# CRITICAL: remove() returns the connection to the pool.
# Without this, connections remain "checked out" and accumulate
# as "idle in transaction" in PostgreSQL.
ScopedSession.remove()
return response
@@ -1366,6 +1431,13 @@ async def check_url(request: Request, call_next):
request.state.token = get_http_authorization_cred(
request.headers.get("Authorization")
)
# Fallback to cookie token for browser sessions
if request.state.token is None and request.cookies.get("token"):
from fastapi.security import HTTPAuthorizationCredentials
request.state.token = HTTPAuthorizationCredentials(
scheme="Bearer", credentials=request.cookies.get("token")
)
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
response = await call_next(request)
@@ -1430,6 +1502,7 @@ app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
app.include_router(prompts.router, prefix="/api/v1/prompts", tags=["prompts"])
app.include_router(tools.router, prefix="/api/v1/tools", tags=["tools"])
app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"])
app.include_router(memories.router, prefix="/api/v1/memories", tags=["memories"])
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
@@ -1439,6 +1512,7 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function
app.include_router(
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
)
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
# SCIM 2.0 API for identity management
@@ -1726,9 +1800,11 @@ async def chat_completion(
except:
pass
return await process_chat_response(
request, response, form_data, user, metadata, model, events, tasks
ctx = build_chat_response_context(
request, form_data, user, model, metadata, tasks, events
)
return await process_chat_response(response, ctx)
except asyncio.CancelledError:
log.info("Chat processing was cancelled")
try:
@@ -1778,6 +1854,16 @@ async def chat_completion(
except Exception as e:
log.debug(f"Error cleaning up: {e}")
pass
# Emit chat:active=false when task completes
try:
if metadata.get("chat_id"):
event_emitter = get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter(
{"type": "chat:active", "data": {"active": False}}
)
except Exception as e:
log.debug(f"Error emitting chat:active: {e}")
if (
metadata.get("session_id")
@@ -1790,6 +1876,10 @@ async def chat_completion(
process_chat(request, form_data, user, metadata, model),
id=metadata["chat_id"],
)
# Emit chat:active=true when task starts
event_emitter = get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter({"type": "chat:active", "data": {"active": True}})
return {"status": True, "task_id": task_id}
else:
return await process_chat(request, form_data, user, metadata, model)
@@ -1930,6 +2020,7 @@ async def get_app_config(request: Request):
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
"enable_public_active_users_count": ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
"enable_easter_eggs": ENABLE_EASTER_EGGS,
**(
{
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
@@ -12,7 +12,6 @@ from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "2f1211949ecc"
down_revision: Union[str, None] = "37f288994c47"
@@ -0,0 +1,247 @@
"""Add prompt history table
Revision ID: 374d2f66af06
Revises: c440947495f3
Create Date: 2026-01-23 17:15:00.000000
"""
from typing import Sequence, Union
import uuid
from alembic import op
import sqlalchemy as sa
revision: str = "374d2f66af06"
down_revision: Union[str, None] = "c440947495f3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# 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.
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,
)
).fetchall()
except Exception:
# Fallback if table doesn't exist (new install)
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,
)
)
# 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
op.drop_table("prompt")
op.rename_table("prompt_new", "prompt")
def downgrade() -> None:
conn = op.get_bind()
# Step 1: Read new data
prompt_table = sa.table(
"prompt",
sa.column("command", sa.String()),
sa.column("name", sa.Text()),
sa.column("created_at", sa.BigInteger()),
sa.column("user_id", sa.Text()),
sa.column("content", sa.Text()),
sa.column("access_control", sa.JSON()),
)
try:
current_data = conn.execute(
sa.select(
prompt_table.c.command,
prompt_table.c.name,
prompt_table.c.created_at,
prompt_table.c.user_id,
prompt_table.c.content,
prompt_table.c.access_control,
)
).fetchall()
except Exception:
current_data = []
# Step 2: Drop history and table
op.drop_table("prompt_history")
op.drop_table("prompt")
# Step 3: Recreate old table (command as PK?)
# Assuming old schema:
op.create_table(
"prompt",
sa.Column("command", sa.String(), primary_key=True),
sa.Column("user_id", sa.String()),
sa.Column("title", sa.Text()),
sa.Column("content", sa.Text()),
sa.Column("timestamp", sa.BigInteger()),
sa.Column("access_control", sa.JSON()),
sa.Column("id", sa.Integer(), nullable=True),
)
# Step 4: Restore data
old_prompt_table = sa.table(
"prompt",
sa.column("command", sa.String()),
sa.column("user_id", sa.String()),
sa.column("title", sa.Text()),
sa.column("content", sa.Text()),
sa.column("timestamp", sa.BigInteger()),
sa.column("access_control", sa.JSON()),
)
for row in current_data:
command = row[0]
name = row[1]
created_at = row[2]
user_id = row[3]
content = row[4]
access_control = row[5]
# Restore leading /
old_command = (
"/" + command if command and not command.startswith("/") else command
)
conn.execute(
sa.insert(old_prompt_table).values(
command=old_command,
user_id=user_id,
title=name,
content=content,
timestamp=created_at,
access_control=access_control,
)
)
@@ -14,7 +14,6 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "37f288994c47"
down_revision: Union[str, None] = "a5c220713937"
@@ -11,7 +11,6 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "38d63c18f30f"
down_revision: Union[str, None] = "3af16a1c9fb6"
@@ -12,7 +12,6 @@ from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "6283dc0e4d8d"
down_revision: Union[str, None] = "3e0e00844bb0"
@@ -11,7 +11,6 @@ import sqlalchemy as sa
from sqlalchemy.sql import table, column, select
import json
revision = "6a39f3d8e55c"
down_revision = "c0fbf31ca0db"
branch_labels = None
@@ -12,7 +12,6 @@ from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "81cc2ce44d79"
down_revision: Union[str, None] = "6283dc0e4d8d"
@@ -0,0 +1,177 @@
"""Add chat_message table
Revision ID: 8452d01d26d7
Revises: 374d2f66af06
Create Date: 2026-02-01 04:00:00.000000
"""
import time
import json
import logging
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
log = logging.getLogger(__name__)
revision: str = "8452d01d26d7"
down_revision: Union[str, None] = "374d2f66af06"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Step 1: Create table
op.create_table(
"chat_message",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("chat_id", sa.Text(), nullable=False, index=True),
sa.Column("user_id", sa.Text(), index=True),
sa.Column("role", sa.Text(), nullable=False),
sa.Column("parent_id", sa.Text(), nullable=True),
sa.Column("content", sa.JSON(), nullable=True),
sa.Column("output", sa.JSON(), nullable=True),
sa.Column("model_id", sa.Text(), nullable=True, index=True),
sa.Column("files", sa.JSON(), nullable=True),
sa.Column("sources", sa.JSON(), nullable=True),
sa.Column("embeds", sa.JSON(), nullable=True),
sa.Column("done", sa.Boolean(), default=True),
sa.Column("status_history", sa.JSON(), nullable=True),
sa.Column("error", sa.JSON(), nullable=True),
sa.Column("usage", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), index=True),
sa.Column("updated_at", sa.BigInteger()),
sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"),
)
# Create composite indexes
op.create_index(
"chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"]
)
op.create_index(
"chat_message_model_created_idx", "chat_message", ["model_id", "created_at"]
)
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()),
sa.column("user_id", sa.Text()),
sa.column("chat", sa.JSON()),
)
chat_message_table = sa.table(
"chat_message",
sa.column("id", sa.Text()),
sa.column("chat_id", sa.Text()),
sa.column("user_id", sa.Text()),
sa.column("role", sa.Text()),
sa.column("parent_id", sa.Text()),
sa.column("content", sa.JSON()),
sa.column("output", sa.JSON()),
sa.column("model_id", sa.Text()),
sa.column("files", sa.JSON()),
sa.column("sources", sa.JSON()),
sa.column("embeds", sa.JSON()),
sa.column("done", sa.Boolean()),
sa.column("status_history", sa.JSON()),
sa.column("error", sa.JSON()),
sa.column("usage", sa.JSON()),
sa.column("created_at", sa.BigInteger()),
sa.column("updated_at", sa.BigInteger()),
)
# Fetch all chats (excluding shared chats which have user_id starting with 'shared-')
chats = conn.execute(
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat).where(
~chat_table.c.user_id.like("shared-%")
)
).fetchall()
now = int(time.time())
messages_inserted = 0
messages_failed = 0
for chat_row in chats:
chat_id = chat_row[0]
user_id = chat_row[1]
chat_data = chat_row[2]
if not chat_data:
continue
# Handle both string and dict chat data
if isinstance(chat_data, str):
try:
chat_data = json.loads(chat_data)
except Exception:
continue
history = chat_data.get("history", {})
messages = history.get("messages", {})
for message_id, message in messages.items():
if not isinstance(message, dict):
continue
role = message.get("role")
if not role:
continue
timestamp = message.get("timestamp", now)
# Normalize timestamp: convert ms to seconds, validate range
if timestamp > 10_000_000_000:
timestamp = timestamp // 1000
# Must be after 2020 and not too far in the future
if timestamp < 1577836800 or timestamp > now + 86400:
timestamp = now
# Use savepoint to allow individual insert failures without aborting transaction
savepoint = conn.begin_nested()
try:
conn.execute(
sa.insert(chat_message_table).values(
id=f"{chat_id}-{message_id}",
chat_id=chat_id,
user_id=user_id,
role=role,
parent_id=message.get("parentId"),
content=message.get("content"),
output=message.get("output"),
model_id=message.get("model"),
files=message.get("files"),
sources=message.get("sources"),
embeds=message.get("embeds"),
done=message.get("done", True),
status_history=message.get("statusHistory"),
error=message.get("error"),
created_at=timestamp,
updated_at=timestamp,
)
)
savepoint.commit()
messages_inserted += 1
except Exception as e:
savepoint.rollback()
messages_failed += 1
log.warning(f"Failed to insert message {message_id}: {e}")
continue
log.info(
f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)"
)
def downgrade() -> None:
op.drop_index("chat_message_user_created_idx", table_name="chat_message")
op.drop_index("chat_message_model_created_idx", table_name="chat_message")
op.drop_index("chat_message_chat_parent_idx", table_name="chat_message")
op.drop_table("chat_message")
@@ -12,7 +12,6 @@ from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "90ef40d4714e"
down_revision: Union[str, None] = "b10670c03dd5"
@@ -0,0 +1,45 @@
"""Add skill table
Revision ID: a1b2c3d4e5f6
Revises: f1e2d3c4b5a6
Create Date: 2026-02-11 09:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from open_webui.migrations.util import get_existing_tables
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "f1e2d3c4b5a6"
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())
if "skill" not in existing_tables:
op.create_table(
"skill",
sa.Column("id", sa.String(), nullable=False, primary_key=True),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("name", sa.Text(), nullable=False, unique=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
)
op.create_index("idx_skill_user_id", "skill", ["user_id"])
op.create_index("idx_skill_updated_at", "skill", ["updated_at"])
def downgrade() -> None:
op.drop_index("idx_skill_updated_at", table_name="skill")
op.drop_index("idx_skill_user_id", table_name="skill")
op.drop_table("skill")
@@ -173,12 +173,10 @@ def upgrade() -> None:
for uid, api_key in users_with_keys:
if api_key:
conn.execute(
sa.text(
"""
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,
@@ -0,0 +1,26 @@
"""add scim column to user table
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-02-13 14:19:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b2c3d4e5f6a7"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("user", sa.Column("scim", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("user", "scim")
@@ -12,7 +12,6 @@ import json
from sqlalchemy.sql import table, column
from sqlalchemy import String, Text, JSON, and_
revision = "c29facfe716b"
down_revision = "c69f45358db4"
branch_labels = None
@@ -11,7 +11,6 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c440947495f3"
down_revision: Union[str, None] = "81cc2ce44d79"
@@ -0,0 +1,369 @@
"""Add access_grant table
Revision ID: f1e2d3c4b5a6
Revises: 8452d01d26d7
Create Date: 2026-02-05 10:00:00.000000
Migrates from JSON access_control columns to normalized access_grant table.
Access control semantics:
- NULL: Public access (all users can read) -> insert user:* for read
- {}: Private/owner-only (no grants) -> insert nothing
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
"""
from typing import Sequence, Union
import time
import uuid
from alembic import op
import sqlalchemy as sa
from open_webui.migrations.util import get_existing_tables
revision: str = "f1e2d3c4b5a6"
down_revision: Union[str, None] = "8452d01d26d7"
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())
# Create access_grant table
if "access_grant" not in existing_tables:
op.create_table(
"access_grant",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("resource_type", sa.Text(), nullable=False),
sa.Column("resource_id", sa.Text(), nullable=False),
sa.Column("principal_type", sa.Text(), nullable=False),
sa.Column("principal_id", sa.Text(), nullable=False),
sa.Column("permission", sa.Text(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.UniqueConstraint(
"resource_type",
"resource_id",
"principal_type",
"principal_id",
"permission",
name="uq_access_grant_grant",
),
)
op.create_index(
"idx_access_grant_resource",
"access_grant",
["resource_type", "resource_id"],
)
op.create_index(
"idx_access_grant_principal",
"access_grant",
["principal_type", "principal_id"],
)
# Backfill existing access_control JSON data
conn = op.get_bind()
# Tables with access_control JSON columns: (table_name, resource_type)
resource_tables = [
("knowledge", "knowledge"),
("prompt", "prompt"),
("tool", "tool"),
("model", "model"),
("note", "note"),
("channel", "channel"),
("file", "file"),
]
now = int(time.time())
inserted = set()
for table_name, resource_type in resource_tables:
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:
continue
for row in rows:
resource_id = row[0]
access_control_json = row[1]
# Handle NULL or JSON "null" = public access (user:* for read)
# Could be Python None (SQL NULL) or string "null" (JSON null)
# EXCEPTION: files with NULL are PRIVATE (owner-only), not public
is_null = (
access_control_json is None
or access_control_json == "null"
or (
isinstance(access_control_json, str)
and access_control_json.strip().lower() == "null"
)
)
if is_null:
# Files: NULL = private (no entry needed, owner has implicit access)
# Other resources: NULL = public (insert user:* for read)
if resource_type == "file":
continue # Private - no entry needed
key = (resource_type, resource_id, "user", "*", "read")
if key not in inserted:
try:
conn.execute(
sa.text("""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": "*",
"permission": "read",
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
continue
# Handle JSON parsing
if isinstance(access_control_json, str):
import json
try:
access_control_json = json.loads(access_control_json)
except Exception:
continue
# Handle {} = private/owner-only - NO entries needed
# Owner access is implicit, no grants to store
if not access_control_json or not isinstance(access_control_json, dict):
continue
# Check if it's effectively empty (no read/write keys with content)
read_data = access_control_json.get("read", {})
write_data = access_control_json.get("write", {})
has_read_grants = read_data.get("group_ids", []) or read_data.get(
"user_ids", []
)
has_write_grants = write_data.get("group_ids", []) or write_data.get(
"user_ids", []
)
if not has_read_grants and not has_write_grants:
# Empty permissions = private, no grants needed
continue
# Extract permissions and insert into access_grant table
for permission in ["read", "write"]:
perm_data = access_control_json.get(permission, {})
if not perm_data:
continue
for group_id in perm_data.get("group_ids", []):
key = (resource_type, resource_id, "group", group_id, permission)
if key in inserted:
continue
try:
conn.execute(
sa.text("""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "group",
"principal_id": group_id,
"permission": permission,
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
for user_id in perm_data.get("user_ids", []):
key = (resource_type, resource_id, "user", user_id, permission)
if key in inserted:
continue
try:
conn.execute(
sa.text("""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": user_id,
"permission": permission,
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
# Drop access_control columns from resource tables
for table_name, _ in resource_tables:
if table_name not in existing_tables:
continue
try:
with op.batch_alter_table(table_name) as batch:
batch.drop_column("access_control")
except Exception:
pass
def downgrade() -> None:
import json
conn = op.get_bind()
# Resource tables mapping: (table_name, resource_type)
resource_tables = [
("knowledge", "knowledge"),
("prompt", "prompt"),
("tool", "tool"),
("model", "model"),
("note", "note"),
("channel", "channel"),
("file", "file"),
]
# Step 1: Re-add access_control columns to resource tables
for table_name, _ in resource_tables:
try:
with op.batch_alter_table(table_name) as batch:
batch.add_column(sa.Column("access_control", sa.JSON(), nullable=True))
except Exception:
pass
# Step 2: Query access_grant table and reconstruct JSON for each resource
for table_name, resource_type in resource_tables:
try:
# Get all grants for this resource type
result = conn.execute(
sa.text("""
SELECT resource_id, principal_type, principal_id, permission
FROM access_grant
WHERE resource_type = :resource_type
"""),
{"resource_type": resource_type},
)
rows = result.fetchall()
except Exception:
continue
# Group by resource_id and reconstruct JSON structure
resource_grants = {}
for row in rows:
resource_id = row[0]
principal_type = row[1]
principal_id = row[2]
permission = row[3]
if resource_id not in resource_grants:
resource_grants[resource_id] = {
"is_public": False,
"read": {"group_ids": [], "user_ids": []},
"write": {"group_ids": [], "user_ids": []},
}
# Handle public access (user:* for read)
if (
principal_type == "user"
and principal_id == "*"
and permission == "read"
):
resource_grants[resource_id]["is_public"] = True
continue
# Add to appropriate list
if permission in ["read", "write"]:
if principal_type == "group":
if (
principal_id
not in resource_grants[resource_id][permission]["group_ids"]
):
resource_grants[resource_id][permission]["group_ids"].append(
principal_id
)
elif principal_type == "user":
if (
principal_id
not in resource_grants[resource_id][permission]["user_ids"]
):
resource_grants[resource_id][permission]["user_ids"].append(
principal_id
)
# Step 3: Update each resource with reconstructed JSON
for resource_id, grants in resource_grants.items():
if grants["is_public"]:
# Public = NULL
access_control_value = None
elif (
not grants["read"]["group_ids"]
and not grants["read"]["user_ids"]
and not grants["write"]["group_ids"]
and not grants["write"]["user_ids"]
):
# No grants = should not happen (would mean no entries), default to {}
access_control_value = json.dumps({})
else:
# Custom permissions
access_control_value = json.dumps(
{
"read": grants["read"],
"write": grants["write"],
}
)
try:
conn.execute(
sa.text(
f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'
),
{"access_control": access_control_value, "id": resource_id},
)
except Exception:
pass
# Step 4: Set all resources WITHOUT entries to private
# For files: NULL means private (owner-only), so leave as NULL
# For other resources: {} means private, so update to {}
if resource_type != "file":
try:
conn.execute(
sa.text(f"""
UPDATE "{table_name}"
SET access_control = :private_value
WHERE id NOT IN (
SELECT DISTINCT resource_id FROM access_grant WHERE resource_type = :resource_type
)
AND access_control IS NULL
"""),
{"private_value": json.dumps({}), "resource_type": resource_type},
)
except Exception:
pass
# For files, NULL stays NULL - no action needed
# Step 5: Drop the access_grant table
op.drop_index("idx_access_grant_principal", table_name="access_grant")
op.drop_index("idx_access_grant_resource", table_name="access_grant")
op.drop_table("access_grant")
+838
View File
@@ -0,0 +1,838 @@
import logging
import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
from sqlalchemy.dialects.postgresql import JSONB
log = logging.getLogger(__name__)
####################
# AccessGrant DB Schema
####################
class AccessGrant(Base):
__tablename__ = "access_grant"
id = Column(Text, primary_key=True)
resource_type = Column(
Text, nullable=False
) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
resource_id = Column(Text, nullable=False)
principal_type = Column(Text, nullable=False) # "user" or "group"
principal_id = Column(
Text, nullable=False
) # user_id, group_id, or "*" (wildcard for public)
permission = Column(Text, nullable=False) # "read" or "write"
created_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint(
"resource_type",
"resource_id",
"principal_type",
"principal_id",
"permission",
name="uq_access_grant_grant",
),
)
class AccessGrantModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
resource_type: str
resource_id: str
principal_type: str
principal_id: str
permission: str
created_at: int
class AccessGrantResponse(BaseModel):
"""Slim grant model for API responses — resource context is implicit from the parent."""
id: str
principal_type: str
principal_id: str
permission: str
@classmethod
def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse":
return cls(
id=grant.id,
principal_type=grant.principal_type,
principal_id=grant.principal_id,
permission=grant.permission,
)
####################
# Conversion utilities
####################
def access_control_to_grants(
resource_type: str,
resource_id: str,
access_control: Optional[dict],
) -> list[dict]:
"""
Convert an old-style access_control JSON dict to a flat list of grant dicts.
Semantics:
- None → public read (user:* read) — except files which are private
- {} → private/owner-only (no grants)
- {read: {group_ids, user_ids}, write: {group_ids, user_ids}} → specific grants
Returns a list of dicts with keys: resource_type, resource_id, principal_type, principal_id, permission
"""
grants = []
if access_control is None:
# NULL → public read (user:* for read)
# Exception: files with NULL are private (owner-only), no grants needed
if resource_type != "file":
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": "*",
"permission": "read",
}
)
return grants
# {} → private/owner-only, no grants
if not access_control:
return grants
# Parse structured permissions
for permission in ["read", "write"]:
perm_data = access_control.get(permission, {})
if not perm_data:
continue
for group_id in perm_data.get("group_ids", []):
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "group",
"principal_id": group_id,
"permission": permission,
}
)
for user_id in perm_data.get("user_ids", []):
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": user_id,
"permission": permission,
}
)
return grants
def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
"""
Normalize direct access_grants payloads from API forms.
Keeps only valid grants and removes duplicates by
(principal_type, principal_id, permission).
"""
if not access_grants:
return []
deduped = {}
for grant in access_grants:
if isinstance(grant, BaseModel):
grant = grant.model_dump()
if not isinstance(grant, dict):
continue
principal_type = grant.get("principal_type")
principal_id = grant.get("principal_id")
permission = grant.get("permission")
if principal_type not in ("user", "group"):
continue
if permission not in ("read", "write"):
continue
if not isinstance(principal_id, str) or not principal_id:
continue
key = (principal_type, principal_id, permission)
deduped[key] = {
"id": (
grant.get("id")
if isinstance(grant.get("id"), str) and grant.get("id")
else str(uuid.uuid4())
),
"principal_type": principal_type,
"principal_id": principal_id,
"permission": permission,
}
return list(deduped.values())
def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes wildcard public-read.
"""
for grant in normalize_access_grants(access_grants):
if (
grant["principal_type"] == "user"
and grant["principal_id"] == "*"
and grant["permission"] == "read"
):
return True
return False
def grants_to_access_control(grants: list) -> Optional[dict]:
"""
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
back to the old-style access_control JSON dict for backward compatibility.
Semantics:
- [] (empty) → {} (private/owner-only)
- Contains user:*:read → None (public), but write grants are preserved
- Otherwise → {read: {group_ids, user_ids}, write: {group_ids, user_ids}}
Note: "public" (user:*:read) still allows additional write permissions
to coexist. When the wildcard read is present the function returns None
for the legacy dict, so callers that need write info should inspect the
grants list directly.
"""
if not grants:
return {} # No grants = private/owner-only
result = {
"read": {"group_ids": [], "user_ids": []},
"write": {"group_ids": [], "user_ids": []},
}
is_public = False
for grant in grants:
if (
grant.principal_type == "user"
and grant.principal_id == "*"
and grant.permission == "read"
):
is_public = True
continue # Don't add wildcard to user_ids list
if grant.permission not in ("read", "write"):
continue
if grant.principal_type == "group":
if grant.principal_id not in result[grant.permission]["group_ids"]:
result[grant.permission]["group_ids"].append(grant.principal_id)
elif grant.principal_type == "user":
if grant.principal_id not in result[grant.permission]["user_ids"]:
result[grant.permission]["user_ids"].append(grant.principal_id)
if is_public:
return None # Public read access
return result
####################
# Table Operations
####################
class AccessGrantsTable:
def grant_access(
self,
resource_type: str,
resource_id: str,
principal_type: str,
principal_id: str,
permission: str,
db: Optional[Session] = None,
) -> Optional[AccessGrantModel]:
"""Add a single access grant. Idempotent (ignores duplicates)."""
with get_db_context(db) as db:
# Check for existing grant
existing = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
)
.first()
)
if existing:
return AccessGrantModel.model_validate(existing)
grant = AccessGrant(
id=str(uuid.uuid4()),
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
created_at=int(time.time()),
)
db.add(grant)
db.commit()
db.refresh(grant)
return AccessGrantModel.model_validate(grant)
def revoke_access(
self,
resource_type: str,
resource_id: str,
principal_type: str,
principal_id: str,
permission: str,
db: Optional[Session] = None,
) -> bool:
"""Remove a single access grant."""
with get_db_context(db) as db:
deleted = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
)
.delete()
)
db.commit()
return deleted > 0
def revoke_all_access(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> int:
"""Remove all access grants for a resource."""
with get_db_context(db) as db:
deleted = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.delete()
)
db.commit()
return deleted
def set_access_control(
self,
resource_type: str,
resource_id: str,
access_control: Optional[dict],
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""
Replace all grants for a resource from an access_control JSON dict.
This is the primary bridge for backward compat with the frontend.
"""
with get_db_context(db) as db:
# Delete all existing grants for this resource
db.query(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
).delete()
# Convert JSON to grant dicts
grant_dicts = access_control_to_grants(
resource_type, resource_id, access_control
)
# Insert new grants
results = []
for grant_dict in grant_dicts:
grant = AccessGrant(
id=str(uuid.uuid4()),
**grant_dict,
created_at=int(time.time()),
)
db.add(grant)
results.append(grant)
db.commit()
return [AccessGrantModel.model_validate(g) for g in results]
def set_access_grants(
self,
resource_type: str,
resource_id: str,
access_grants: Optional[list],
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""
Replace all grants for a resource from a direct access_grants list.
"""
with get_db_context(db) as db:
db.query(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
).delete()
normalized_grants = normalize_access_grants(access_grants)
results = []
for grant_dict in normalized_grants:
grant = AccessGrant(
id=grant_dict["id"],
resource_type=resource_type,
resource_id=resource_id,
principal_type=grant_dict["principal_type"],
principal_id=grant_dict["principal_id"],
permission=grant_dict["permission"],
created_at=int(time.time()),
)
db.add(grant)
results.append(grant)
db.commit()
return [AccessGrantModel.model_validate(g) for g in results]
def get_access_control(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> Optional[dict]:
"""
Reconstruct the old-style access_control JSON dict from grants.
For backward compat with the frontend.
"""
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.all()
)
grant_models = [AccessGrantModel.model_validate(g) for g in grants]
return grants_to_access_control(grant_models)
def get_grants_by_resource(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""Get all grants for a specific resource."""
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.all()
)
return [AccessGrantModel.model_validate(g) for g in grants]
def has_access(
self,
user_id: str,
resource_type: str,
resource_id: str,
permission: str = "read",
user_group_ids: Optional[set[str]] = None,
db: Optional[Session] = None,
) -> bool:
"""
Check if a user has the specified permission on a resource.
Access is granted if any of the following is true:
- There's a grant for user:* (public) with the requested permission
- There's a grant for the specific user with the requested permission
- There's a grant for any of the user's groups with the requested permission
"""
with get_db_context(db) as db:
# Build conditions for matching grants
conditions = [
# Public access
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
),
# Direct user access
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
),
]
# Group access
if user_group_ids is None:
from open_webui.models.groups import Groups
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if user_group_ids:
conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(user_group_ids),
)
)
exists = (
db.query(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == resource_id,
AccessGrant.permission == permission,
or_(*conditions),
)
.first()
)
return exists is not None
def get_accessible_resource_ids(
self,
user_id: str,
resource_type: str,
resource_ids: list[str],
permission: str = "read",
user_group_ids: Optional[set[str]] = None,
db: Optional[Session] = None,
) -> set[str]:
"""
Batch check: return the subset of resource_ids that the user can access.
This replaces calling has_access() in a loop (N+1) with a single query.
"""
if not resource_ids:
return set()
with get_db_context(db) as db:
conditions = [
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
),
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
),
]
if user_group_ids is None:
from open_webui.models.groups import Groups
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if user_group_ids:
conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(user_group_ids),
)
)
rows = (
db.query(AccessGrant.resource_id)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id.in_(resource_ids),
AccessGrant.permission == permission,
or_(*conditions),
)
.distinct()
.all()
)
return {row[0] for row in rows}
def get_users_with_access(
self,
resource_type: str,
resource_id: str,
permission: str = "read",
db: Optional[Session] = None,
) -> list:
"""
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
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
permission=permission,
)
.all()
)
# Check for public access
for grant in grants:
if grant.principal_type == "user" and grant.principal_id == "*":
result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
return result.get("users", [])
user_ids_with_access = set()
for grant in grants:
if grant.principal_type == "user":
user_ids_with_access.add(grant.principal_id)
elif grant.principal_type == "group":
group_user_ids = Groups.get_group_user_ids_by_id(
grant.principal_id, db=db
)
if group_user_ids:
user_ids_with_access.update(group_user_ids)
if not user_ids_with_access:
return []
return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
def has_permission_filter(
self,
db,
query,
DocumentModel,
filter: dict,
resource_type: str,
permission: str = "read",
):
"""
Apply access control filtering to a SQLAlchemy query by JOINing with access_grant.
This replaces the old JSON-column-based filtering with a proper relational JOIN.
"""
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
if permission == "read_only":
return self._has_read_only_permission_filter(
db, query, DocumentModel, filter, resource_type
)
# Build principal conditions
principal_conditions = []
if group_ids or user_id:
# Public access: user:* read
principal_conditions.append(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
)
)
if user_id:
# Owner always has access
principal_conditions.append(DocumentModel.user_id == user_id)
# Direct user grant
principal_conditions.append(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
)
if group_ids:
# Group grants
principal_conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
)
if not principal_conditions:
return query
# LEFT JOIN access_grant and filter
# We use a subquery approach to avoid duplicates from multiple matching grants
from sqlalchemy import exists as sa_exists, select
grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == permission,
or_(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
),
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Owner OR has a matching grant
owner_or_grant = [grant_exists]
if user_id:
owner_or_grant.append(DocumentModel.user_id == user_id)
query = query.filter(or_(*owner_or_grant))
return query
def _has_read_only_permission_filter(
self,
db,
query,
DocumentModel,
filter: dict,
resource_type: str,
):
"""
Filter for items where user has read BUT NOT write access.
Public items are NOT considered read_only.
"""
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
from sqlalchemy import exists as sa_exists, select
# Has read grant (not public)
read_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "read",
or_(
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Does NOT have write grant
write_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "write",
or_(
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Is NOT public
public_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "read",
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
)
.correlate(DocumentModel)
.exists()
)
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
# Not owner
if user_id:
conditions.append(DocumentModel.user_id != user_id)
query = query.filter(and_(*conditions))
return query
AccessGrants = AccessGrantsTable()
+21 -6
View File
@@ -4,8 +4,9 @@ from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import UserModel, UserProfileImageResponse, Users
from pydantic import BaseModel
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
log = logging.getLogger(__name__)
@@ -74,6 +75,13 @@ class SignupForm(BaseModel):
password: str
profile_image_url: Optional[str] = "/user.png"
@field_validator("profile_image_url")
@classmethod
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
if v is not None:
return validate_profile_image_url(v)
return v
class AddUserForm(SignupForm):
role: Optional[str] = "pending"
@@ -155,10 +163,17 @@ class AuthsTable:
log.info(f"authenticate_user_by_email: {email}")
try:
with get_db_context(db) as db:
auth = db.query(Auth).filter_by(email=email, active=True).first()
if auth:
user = Users.get_user_by_id(auth.id, db=db)
return user
# Single JOIN query instead of two separate queries
result = (
db.query(Auth, User)
.join(User, Auth.id == User.id)
.filter(Auth.email == email, Auth.active == True)
.first()
)
if result:
_, user = result
return UserModel.model_validate(user)
return None
except Exception:
return None
+51 -58
View File
@@ -7,8 +7,12 @@ from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.access_grants import (
AccessGrantModel,
AccessGrants,
)
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.dialects.postgresql import JSONB
@@ -47,7 +51,6 @@ class Channel(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
@@ -76,7 +79,7 @@ class ChannelModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int # timestamp in epoch (time_ns)
@@ -237,7 +240,7 @@ class ChannelForm(BaseModel):
is_private: Optional[bool] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
group_ids: Optional[list[str]] = None
user_ids: Optional[list[str]] = None
@@ -252,6 +255,20 @@ class ChannelWebhookForm(BaseModel):
class ChannelTable:
def _get_access_grants(
self, channel_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("channel", channel_id, db=db)
def _to_channel_model(
self, channel: Channel, db: Optional[Session] = None
) -> ChannelModel:
channel_data = ChannelModel.model_validate(channel).model_dump(
exclude={"access_grants"}
)
access_grants = self._get_access_grants(channel_data["id"], db=db)
channel_data["access_grants"] = access_grants
return ChannelModel.model_validate(channel_data)
def _collect_unique_user_ids(
self,
@@ -316,16 +333,17 @@ class ChannelTable:
with get_db_context(db) as db:
channel = ChannelModel(
**{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"type": form_data.type if form_data.type else None,
"name": form_data.name.lower(),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
"access_grants": [],
}
)
new_channel = Channel(**channel.model_dump())
new_channel = Channel(**channel.model_dump(exclude={"access_grants"}))
if form_data.type in ["group", "dm"]:
users = self._collect_unique_user_ids(
@@ -342,54 +360,25 @@ class ChannelTable:
db.add_all(memberships)
db.add(new_channel)
db.commit()
return channel
AccessGrants.set_access_grants(
"channel", new_channel.id, form_data.access_grants, db=db
)
return self._to_channel_model(new_channel, db=db)
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
with get_db_context(db) as db:
channels = db.query(Channel).all()
return [ChannelModel.model_validate(channel) for channel in channels]
return [self._to_channel_model(channel, db=db) for channel in channels]
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
# Public access
conditions = []
if group_ids or user_id:
conditions.extend(
[
Channel.access_control.is_(None),
cast(Channel.access_control, String) == "null",
]
)
# User-level permission
if user_id:
conditions.append(Channel.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Channel.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Channel.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Channel,
filter=filter,
resource_type="channel",
permission=permission,
)
def get_channels_by_user_id(
self, user_id: str, db: Optional[Session] = None
@@ -428,7 +417,7 @@ class ChannelTable:
standard_channels = query.all()
all_channels = membership_channels + standard_channels
return [ChannelModel.model_validate(c) for c in all_channels]
return [self._to_channel_model(c, db=db) for c in all_channels]
def get_dm_channel_by_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
@@ -463,7 +452,7 @@ class ChannelTable:
.first()
)
return ChannelModel.model_validate(channel) if channel else None
return self._to_channel_model(channel, db=db) if channel else None
def add_members_to_channel(
self,
@@ -722,7 +711,7 @@ class ChannelTable:
try:
with get_db_context(db) as db:
channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None
return self._to_channel_model(channel, db=db) if channel else None
except Exception:
return None
@@ -735,7 +724,7 @@ class ChannelTable:
)
channel_ids = [cf.channel_id for cf in channel_files]
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
return [ChannelModel.model_validate(channel) for channel in channels]
return [self._to_channel_model(channel, db=db) for channel in channels]
def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str, db: Optional[Session] = None
@@ -783,7 +772,7 @@ class ChannelTable:
.first()
)
if membership:
allowed_channels.append(ChannelModel.model_validate(channel))
allowed_channels.append(self._to_channel_model(channel, db=db))
continue
# --- Case B: standard channel => rely on ACL permissions ---
@@ -798,7 +787,7 @@ class ChannelTable:
allowed = query.first()
if allowed:
allowed_channels.append(ChannelModel.model_validate(allowed))
allowed_channels.append(self._to_channel_model(allowed, db=db))
return allowed_channels
@@ -832,7 +821,7 @@ class ChannelTable:
.first()
)
if membership:
return ChannelModel.model_validate(channel)
return self._to_channel_model(channel, db=db)
else:
return None
@@ -854,7 +843,7 @@ class ChannelTable:
channel_allowed = query.first()
return (
ChannelModel.model_validate(channel_allowed)
self._to_channel_model(channel_allowed, db=db)
if channel_allowed
else None
)
@@ -874,11 +863,14 @@ class ChannelTable:
channel.data = form_data.data
channel.meta = form_data.meta
channel.access_control = form_data.access_control
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"channel", id, form_data.access_grants, db=db
)
channel.updated_at = int(time.time_ns())
db.commit()
return ChannelModel.model_validate(channel) if channel else None
return self._to_channel_model(channel, db=db) if channel else None
def add_file_to_channel_by_id(
self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None
@@ -947,6 +939,7 @@ class ChannelTable:
def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("channel", id, db=db)
db.query(Channel).filter(Channel.id == id).delete()
db.commit()
return True
+644
View File
@@ -0,0 +1,644 @@
import json
import time
import uuid
from typing import Any, Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
BigInteger,
Boolean,
Column,
ForeignKey,
Text,
JSON,
Index,
func,
)
####################
# Helpers
####################
def _normalize_timestamp(timestamp: int) -> float:
"""Normalize and validate timestamp. Returns current time if invalid."""
now = time.time()
# Convert milliseconds to seconds if needed
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000
# Validate: must be after 2020 and not in the future (with 1 day tolerance)
min_valid = 1577836800 # 2020-01-01 00:00:00 UTC
max_valid = now + 86400 # 1 day in the future (clock skew tolerance)
if timestamp < min_valid or timestamp > max_valid:
return now
return timestamp
####################
# ChatMessage DB Schema
####################
class ChatMessage(Base):
__tablename__ = "chat_message"
# Identity
id = Column(Text, primary_key=True)
chat_id = Column(
Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id = Column(Text, index=True)
# Structure
role = Column(Text, nullable=False) # user, assistant, system
parent_id = Column(Text, nullable=True)
# Content
content = Column(JSON, nullable=True) # Can be str or list of blocks
output = Column(JSON, nullable=True)
# Model (for assistant messages)
model_id = Column(Text, nullable=True, index=True)
# Attachments
files = Column(JSON, nullable=True)
sources = Column(JSON, nullable=True)
embeds = Column(JSON, nullable=True)
# Status
done = Column(Boolean, default=True)
status_history = Column(JSON, nullable=True)
error = Column(JSON, nullable=True)
# Usage (tokens, timing, etc.)
usage = Column(JSON, nullable=True)
# Timestamps
created_at = Column(BigInteger, index=True)
updated_at = Column(BigInteger)
__table_args__ = (
Index("chat_message_chat_parent_idx", "chat_id", "parent_id"),
Index("chat_message_model_created_idx", "model_id", "created_at"),
Index("chat_message_user_created_idx", "user_id", "created_at"),
)
####################
# Pydantic Models
####################
class ChatMessageModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
chat_id: str
user_id: str
role: str
parent_id: Optional[str] = None
content: Optional[Any] = None # str or list of blocks
output: Optional[list] = None
model_id: Optional[str] = None
files: Optional[list] = None
sources: Optional[list] = None
embeds: Optional[list] = None
done: bool = True
status_history: Optional[list] = None
error: Optional[dict | str] = None
usage: Optional[dict] = None
created_at: int
updated_at: int
####################
# Table Operations
####################
class ChatMessageTable:
def upsert_message(
self,
message_id: str,
chat_id: str,
user_id: str,
data: dict,
db: Optional[Session] = None,
) -> Optional[ChatMessageModel]:
"""Insert or update a chat message."""
with get_db_context(db) as db:
now = int(time.time())
timestamp = data.get("timestamp", now)
# Use composite ID: {chat_id}-{message_id}
composite_id = f"{chat_id}-{message_id}"
existing = db.get(ChatMessage, composite_id)
if existing:
# Update existing
if "role" in data:
existing.role = data["role"]
if "parent_id" in data:
existing.parent_id = data.get("parent_id") or data.get("parentId")
if "content" in data:
existing.content = data.get("content")
if "output" in data:
existing.output = data.get("output")
if "model_id" in data or "model" in data:
existing.model_id = data.get("model_id") or data.get("model")
if "files" in data:
existing.files = data.get("files")
if "sources" in data:
existing.sources = data.get("sources")
if "embeds" in data:
existing.embeds = data.get("embeds")
if "done" in data:
existing.done = data.get("done", True)
if "status_history" in data or "statusHistory" in data:
existing.status_history = data.get("status_history") or data.get(
"statusHistory"
)
if "error" in data:
existing.error = data.get("error")
# Extract usage - check direct field first, then info.usage
usage = data.get("usage")
if not usage:
info = data.get("info", {})
usage = info.get("usage") if info else None
if usage:
existing.usage = usage
existing.updated_at = now
db.commit()
db.refresh(existing)
return ChatMessageModel.model_validate(existing)
else:
# Insert new
# Extract usage - check direct field first, then info.usage
usage = data.get("usage")
if not usage:
info = data.get("info", {})
usage = info.get("usage") if info else None
message = ChatMessage(
id=composite_id,
chat_id=chat_id,
user_id=user_id,
role=data.get("role", "user"),
parent_id=data.get("parent_id") or data.get("parentId"),
content=data.get("content"),
output=data.get("output"),
model_id=data.get("model_id") or data.get("model"),
files=data.get("files"),
sources=data.get("sources"),
embeds=data.get("embeds"),
done=data.get("done", True),
status_history=data.get("status_history")
or data.get("statusHistory"),
error=data.get("error"),
usage=usage,
created_at=timestamp,
updated_at=now,
)
db.add(message)
db.commit()
db.refresh(message)
return ChatMessageModel.model_validate(message)
def get_message_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatMessageModel]:
with get_db_context(db) as db:
message = db.get(ChatMessage, id)
return ChatMessageModel.model_validate(message) if message else None
def get_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
messages = (
db.query(ChatMessage)
.filter_by(chat_id=chat_id)
.order_by(ChatMessage.created_at.asc())
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def get_messages_by_user_id(
self,
user_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
messages = (
db.query(ChatMessage)
.filter_by(user_id=user_id)
.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def get_messages_by_model_id(
self,
model_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
skip: int = 0,
limit: int = 100,
db: Optional[Session] = None,
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
query = db.query(ChatMessage).filter_by(model_id=model_id)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
messages = (
query.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def get_chat_ids_by_model_id(
self,
model_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[str]:
"""Get distinct chat_ids that used a specific model."""
with get_db_context(db) as db:
query = db.query(
ChatMessage.chat_id,
func.max(ChatMessage.created_at).label("last_message_at"),
).filter(ChatMessage.model_id == model_id)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
# Group by chat_id and order by most recent message in each chat
chat_ids = (
query.group_by(ChatMessage.chat_id)
.order_by(func.max(ChatMessage.created_at).desc())
.offset(skip)
.limit(limit)
.all()
)
return [chat_id for chat_id, _ in chat_ids]
def delete_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
db.query(ChatMessage).filter_by(chat_id=chat_id).delete()
db.commit()
return True
# Analytics methods
def get_message_count_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.model_id).all()
return {row.model_id: row.count for row in results}
def get_token_usage_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, dict]:
"""Aggregate token usage by model using database-level aggregation."""
with get_db_context(db) as db:
from sqlalchemy import func, cast, Integer
from open_webui.models.groups import GroupMember
dialect = db.bind.dialect.name
if dialect == "sqlite":
input_tokens = cast(
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
)
output_tokens = cast(
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
)
elif dialect == "postgresql":
# Use json_extract_path_text for PostgreSQL JSON columns
input_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
Integer,
)
output_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
Integer,
)
else:
raise NotImplementedError(f"Unsupported dialect: {dialect}")
query = db.query(
ChatMessage.model_id,
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
func.count(ChatMessage.id).label("message_count"),
).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
ChatMessage.usage.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.model_id).all()
return {
row.model_id: {
"input_tokens": row.input_tokens,
"output_tokens": row.output_tokens,
"total_tokens": row.input_tokens + row.output_tokens,
"message_count": row.message_count,
}
for row in results
}
def get_token_usage_by_user(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, dict]:
"""Aggregate token usage by user using database-level aggregation."""
with get_db_context(db) as db:
from sqlalchemy import func, cast, Integer
dialect = db.bind.dialect.name
if dialect == "sqlite":
input_tokens = cast(
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
)
output_tokens = cast(
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
)
elif dialect == "postgresql":
# Use json_extract_path_text for PostgreSQL JSON columns
input_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
Integer,
)
output_tokens = cast(
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
Integer,
)
else:
raise NotImplementedError(f"Unsupported dialect: {dialect}")
query = db.query(
ChatMessage.user_id,
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
func.count(ChatMessage.id).label("message_count"),
).filter(
ChatMessage.role == "assistant",
ChatMessage.user_id.isnot(None),
ChatMessage.usage.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.group_by(ChatMessage.user_id).all()
return {
row.user_id: {
"input_tokens": row.input_tokens,
"output_tokens": row.output_tokens,
"total_tokens": row.input_tokens + row.output_tokens,
"message_count": row.message_count,
}
for row in results
}
def get_message_count_by_user(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.user_id, func.count(ChatMessage.id).label("count")
).filter(~ChatMessage.user_id.like("shared-%"))
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.user_id).all()
return {row.user_id: row.count for row in results}
def get_message_count_by_chat(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
).filter(~ChatMessage.user_id.like("shared-%"))
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.chat_id).all()
return {row.chat_id: row.count for row in results}
def get_daily_message_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by day and model."""
with get_db_context(db) as db:
from datetime import datetime, timedelta
from open_webui.models.groups import GroupMember
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.all()
# Group by date -> model -> count
daily_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
date_str = datetime.fromtimestamp(
_normalize_timestamp(timestamp)
).strftime("%Y-%m-%d")
if date_str not in daily_counts:
daily_counts[date_str] = {}
daily_counts[date_str][model_id] = (
daily_counts[date_str].get(model_id, 0) + 1
)
# Fill in missing days
if start_date and end_date:
current = datetime.fromtimestamp(_normalize_timestamp(start_date))
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
while current <= end_dt:
date_str = current.strftime("%Y-%m-%d")
if date_str not in daily_counts:
daily_counts[date_str] = {}
current += timedelta(days=1)
return daily_counts
def get_hourly_message_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by hour and model."""
with get_db_context(db) as db:
from datetime import datetime, timedelta
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.all()
# Group by hour -> model -> count
hourly_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
hour_str = datetime.fromtimestamp(
_normalize_timestamp(timestamp)
).strftime("%Y-%m-%d %H:00")
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
hourly_counts[hour_str][model_id] = (
hourly_counts[hour_str].get(model_id, 0) + 1
)
# Fill in missing hours
if start_date and end_date:
current = datetime.fromtimestamp(
_normalize_timestamp(start_date)
).replace(minute=0, second=0, microsecond=0)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
while current <= end_dt:
hour_str = current.strftime("%Y-%m-%d %H:00")
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
current += timedelta(hours=1)
return hourly_counts
ChatMessages = ChatMessageTable()
+192 -53
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders
from open_webui.models.chat_messages import ChatMessage, ChatMessages
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
from pydantic import BaseModel, ConfigDict
@@ -168,6 +169,14 @@ class ChatTitleIdResponse(BaseModel):
created_at: int
class SharedChatResponse(BaseModel):
id: str
title: str
share_id: Optional[str] = None
updated_at: int
created_at: int
class ChatListResponse(BaseModel):
items: list[ChatModel]
total: int
@@ -306,6 +315,24 @@ class ChatTable:
db.add(chat_item)
db.commit()
db.refresh(chat_item)
# Dual-write initial messages to chat_message table
try:
history = form_data.chat.get("history", {})
messages = history.get("messages", {})
for message_id, message in messages.items():
if isinstance(message, dict) and message.get("role"):
ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning(
f"Failed to write initial messages to chat_message table: {e}"
)
return ChatModel.model_validate(chat_item) if chat_item else None
def _chat_import_form_to_chat_model(
@@ -348,6 +375,25 @@ class ChatTable:
db.add_all(chats)
db.commit()
# Dual-write messages to chat_message table
try:
for form_data, chat_obj in zip(chat_import_forms, chats):
history = form_data.chat.get("history", {})
messages = history.get("messages", {})
for message_id, message in messages.items():
if isinstance(message, dict) and message.get("role"):
ChatMessages.upsert_message(
message_id=message_id,
chat_id=chat_obj.id,
user_id=user_id,
data=message,
)
except Exception as e:
log.warning(
f"Failed to write imported messages to chat_message table: {e}"
)
return [ChatModel.model_validate(chat) for chat in chats]
def update_chat_by_id(
@@ -385,22 +431,29 @@ class ChatTable:
def update_chat_tags_by_id(
self, id: str, tags: list[str], user
) -> Optional[ChatModel]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
with get_db_context() as db:
chat = db.get(Chat, id)
if chat is None:
return None
self.delete_all_tags_by_id_and_user_id(id, user.id)
old_tags = chat.meta.get("tags", [])
new_tags = [t for t in tags if t.replace(" ", "_").lower() != "none"]
new_tag_ids = [t.replace(" ", "_").lower() for t in new_tags]
for tag in chat.meta.get("tags", []):
if self.count_chats_by_tag_name_and_user_id(tag, user.id) == 0:
Tags.delete_tag_by_name_and_user_id(tag, user.id)
# Single meta update
chat.meta = {**chat.meta, "tags": new_tag_ids}
db.commit()
db.refresh(chat)
for tag_name in tags:
if tag_name.lower() == "none":
continue
# Batch-create any missing tag rows
Tags.ensure_tags_exist(new_tags, user.id, db=db)
self.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, tag_name)
return self.get_chat_by_id(id)
# Clean up orphaned old tags in one query
removed = set(old_tags) - set(new_tag_ids)
if removed:
self.delete_orphan_tags_for_user(list(removed), user.id, db=db)
return ChatModel.model_validate(chat)
def get_chat_title_by_id(self, id: str) -> Optional[str]:
chat = self.get_chat_by_id(id)
@@ -450,6 +503,18 @@ class ChatTable:
history["currentId"] = message_id
chat["history"] = history
# Dual-write to chat_message table
try:
ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=self.get_chat_by_id(id).user_id,
data=history["messages"][message_id],
)
except Exception as e:
log.warning(f"Failed to write to chat_message table: {e}")
return self.update_chat_by_id(id, chat)
def add_message_status_to_chat_by_id_and_message_id(
@@ -563,6 +628,13 @@ class ChatTable:
) -> bool:
try:
with get_db_context(db) as db:
# Use subquery to delete chat_messages for shared chats
shared_chat_id_subquery = (
db.query(Chat.id).filter_by(user_id=f"shared-{chat_id}").subquery()
)
db.query(ChatMessage).filter(
ChatMessage.chat_id.in_(shared_chat_id_subquery)
).delete(synchronize_session=False)
db.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete()
db.commit()
@@ -675,6 +747,51 @@ class ChatTable:
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_shared_chat_list_by_user_id(
self,
user_id: str,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db_context(db) as db:
query = (
db.query(Chat)
.filter_by(user_id=user_id)
.filter(Chat.share_id.isnot(None))
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction:
if not getattr(Chat, order_by, None):
raise ValueError("Invalid order_by field")
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chat_list_by_user_id(
self,
user_id: str,
@@ -1013,29 +1130,23 @@ class ChatTable:
# Check if there are any tags to filter, it should have all the tags
if "none" in tag_ids:
query = query.filter(
text(
"""
query = query.filter(text("""
NOT EXISTS (
SELECT 1
FROM json_each(Chat.meta, '$.tags') AS tag
)
"""
)
)
"""))
elif tag_ids:
query = query.filter(
and_(
*[
text(
f"""
text(f"""
EXISTS (
SELECT 1
FROM json_each(Chat.meta, '$.tags') AS tag
WHERE tag.value = :tag_id_{tag_idx}
)
"""
).params(**{f"tag_id_{tag_idx}": tag_id})
""").params(**{f"tag_id_{tag_idx}": tag_id})
for tag_idx, tag_id in enumerate(tag_ids)
]
)
@@ -1071,29 +1182,23 @@ class ChatTable:
# Check if there are any tags to filter, it should have all the tags
if "none" in tag_ids:
query = query.filter(
text(
"""
query = query.filter(text("""
NOT EXISTS (
SELECT 1
FROM json_array_elements_text(Chat.meta->'tags') AS tag
)
"""
)
)
"""))
elif tag_ids:
query = query.filter(
and_(
*[
text(
f"""
text(f"""
EXISTS (
SELECT 1
FROM json_array_elements_text(Chat.meta->'tags') AS tag
WHERE tag = :tag_id_{tag_idx}
)
"""
).params(**{f"tag_id_{tag_idx}": tag_id})
""").params(**{f"tag_id_{tag_idx}": tag_id})
for tag_idx, tag_id in enumerate(tag_ids)
]
)
@@ -1169,8 +1274,8 @@ class ChatTable:
) -> list[TagModel]:
with get_db_context(db) as db:
chat = db.get(Chat, id)
tags = chat.meta.get("tags", [])
return [Tags.get_tag_by_name_and_user_id(tag, user_id) for tag in tags]
tag_ids = chat.meta.get("tags", [])
return Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db)
def get_chat_list_by_user_id_and_tag_name(
self,
@@ -1211,20 +1316,16 @@ class ChatTable:
def add_chat_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
tag = Tags.get_tag_by_name_and_user_id(tag_name, user_id)
if tag is None:
tag = Tags.insert_new_tag(tag_name, user_id)
tag_id = tag_name.replace(" ", "_").lower()
Tags.ensure_tags_exist([tag_name], user_id, db=db)
try:
with get_db_context(db) as db:
chat = db.get(Chat, id)
tag_id = tag.id
if tag_id not in chat.meta.get("tags", []):
chat.meta = {
**chat.meta,
"tags": list(set(chat.meta.get("tags", []) + [tag_id])),
}
db.commit()
db.refresh(chat)
return ChatModel.model_validate(chat)
@@ -1234,40 +1335,53 @@ class ChatTable:
def count_chats_by_tag_name_and_user_id(
self, tag_name: str, user_id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db: # Assuming `get_db()` returns a session object
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id, archived=False)
# Normalize the tag_name for consistency
tag_id = tag_name.replace(" ", "_").lower()
if db.bind.dialect.name == "sqlite":
# SQLite JSON1 support for querying the tags inside the `meta` JSON field
query = query.filter(
text(
f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)"
"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)"
)
).params(tag_id=tag_id)
elif db.bind.dialect.name == "postgresql":
# PostgreSQL JSONB support for querying the tags inside the `meta` JSON field
query = query.filter(
text(
"EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)"
)
).params(tag_id=tag_id)
else:
raise NotImplementedError(
f"Unsupported dialect: {db.bind.dialect.name}"
)
# Get the count of matching records
count = query.count()
return query.count()
# Debugging output for inspection
log.info(f"Count of chats for tag '{tag_name}': {count}")
def delete_orphan_tags_for_user(
self,
tag_ids: list[str],
user_id: str,
threshold: int = 0,
db: Optional[Session] = None,
) -> None:
"""Delete tag rows from *tag_ids* that appear in at most *threshold*
non-archived chats for *user_id*. One query to find orphans, one to
delete them.
return count
Use threshold=0 after a tag is already removed from a chat's meta.
Use threshold=1 when the chat itself is about to be deleted (the
referencing chat still exists at query time).
"""
if not tag_ids:
return
with get_db_context(db) as db:
orphans = []
for tag_id in tag_ids:
count = self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=db)
if count <= threshold:
orphans.append(tag_id)
Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=db)
def count_chats_by_folder_id_and_user_id(
self, folder_id: str, user_id: str, db: Optional[Session] = None
@@ -1319,6 +1433,7 @@ class ChatTable:
def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
db.query(ChatMessage).filter_by(chat_id=id).delete()
db.query(Chat).filter_by(id=id).delete()
db.commit()
@@ -1331,6 +1446,7 @@ class ChatTable:
) -> bool:
try:
with get_db_context(db) as db:
db.query(ChatMessage).filter_by(chat_id=id).delete()
db.query(Chat).filter_by(id=id, user_id=user_id).delete()
db.commit()
@@ -1345,6 +1461,12 @@ class ChatTable:
with get_db_context(db) as db:
self.delete_shared_chats_by_user_id(user_id, db=db)
chat_id_subquery = (
db.query(Chat.id).filter_by(user_id=user_id).subquery()
)
db.query(ChatMessage).filter(
ChatMessage.chat_id.in_(chat_id_subquery)
).delete(synchronize_session=False)
db.query(Chat).filter_by(user_id=user_id).delete()
db.commit()
@@ -1357,6 +1479,14 @@ class ChatTable:
) -> bool:
try:
with get_db_context(db) as db:
chat_id_subquery = (
db.query(Chat.id)
.filter_by(user_id=user_id, folder_id=folder_id)
.subquery()
)
db.query(ChatMessage).filter(
ChatMessage.chat_id.in_(chat_id_subquery)
).delete(synchronize_session=False)
db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete()
db.commit()
@@ -1390,6 +1520,15 @@ class ChatTable:
chats_by_user = db.query(Chat).filter_by(user_id=user_id).all()
shared_chat_ids = [f"shared-{chat.id}" for chat in chats_by_user]
# Use subquery to delete chat_messages for shared chats
shared_id_subq = (
db.query(Chat.id)
.filter(Chat.user_id.in_(shared_chat_ids))
.subquery()
)
db.query(ChatMessage).filter(
ChatMessage.chat_id.in_(shared_id_subq)
).delete(synchronize_session=False)
db.query(Chat).filter(Chat.user_id.in_(shared_chat_ids)).delete()
db.commit()
+21 -12
View File
@@ -191,6 +191,23 @@ class FeedbackTable:
except Exception:
return None
def get_feedbacks_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
"""Get all feedbacks for a specific chat."""
try:
with get_db_context(db) as db:
# meta.chat_id stores the chat reference
feedbacks = (
db.query(Feedback)
.filter(Feedback.meta["chat_id"].as_string() == chat_id)
.order_by(Feedback.created_at.desc())
.all()
)
return [FeedbackModel.model_validate(fb) for fb in feedbacks]
except Exception:
return []
def get_feedback_items(
self,
filter: dict = {},
@@ -460,23 +477,15 @@ class FeedbackTable:
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
feedbacks = db.query(Feedback).filter_by(user_id=user_id).all()
if not feedbacks:
return False
for feedback in feedbacks:
db.delete(feedback)
result = db.query(Feedback).filter_by(user_id=user_id).delete()
db.commit()
return True
return result > 0
def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
feedbacks = db.query(Feedback).all()
if not feedbacks:
return False
for feedback in feedbacks:
db.delete(feedback)
result = db.query(Feedback).delete()
db.commit()
return True
return result > 0
Feedbacks = FeedbackTable()
+21 -7
View File
@@ -4,7 +4,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, model_validator
from sqlalchemy import BigInteger, Column, String, Text, JSON
log = logging.getLogger(__name__)
@@ -26,8 +26,6 @@ class File(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -45,8 +43,6 @@ class FileModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: Optional[int] # timestamp in epoch
updated_at: Optional[int] # timestamp in epoch
@@ -63,6 +59,25 @@ class FileMeta(BaseModel):
model_config = ConfigDict(extra="allow")
@model_validator(mode="before")
@classmethod
def sanitize_meta(cls, data):
"""Sanitize metadata fields to handle malformed legacy data."""
if not isinstance(data, dict):
return data
# Handle content_type that may be a list like ['application/pdf', None]
content_type = data.get("content_type")
if isinstance(content_type, list):
# Extract first non-None string value
data["content_type"] = next(
(item for item in content_type if isinstance(item, str)), None
)
elif content_type is not None and not isinstance(content_type, str):
data["content_type"] = None
return data
class FileModelResponse(BaseModel):
id: str
@@ -74,7 +89,7 @@ class FileModelResponse(BaseModel):
meta: FileMeta
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
model_config = ConfigDict(extra="allow")
@@ -94,7 +109,6 @@ class FileForm(BaseModel):
path: str
data: dict = {}
meta: dict = {}
access_control: Optional[dict] = None
class FileUpdateForm(BaseModel):
-1
View File
@@ -11,7 +11,6 @@ from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
log = logging.getLogger(__name__)
+23 -3
View File
@@ -195,6 +195,25 @@ class FunctionsTable:
except Exception:
return None
def get_functions_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[FunctionModel]:
"""
Batch fetch multiple functions by their IDs in a single query.
Returns functions in the same order as the input IDs (None entries filtered out).
"""
if not ids:
return []
try:
with get_db_context(db) as db:
functions = db.query(Function).filter(Function.id.in_(ids)).all()
# Create a dict for O(1) lookup
func_dict = {f.id: FunctionModel.model_validate(f) for f in functions}
# Return in original order, filtering out any not found
return [func_dict[id] for id in ids if id in func_dict]
except Exception:
return []
def get_functions(
self, active_only=False, include_valves=False, db: Optional[Session] = None
) -> list[FunctionModel | FunctionWithValvesModel]:
@@ -299,7 +318,7 @@ class FunctionsTable:
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id, db=db)
return FunctionModel.model_validate(function)
except Exception:
return None
@@ -319,7 +338,7 @@ class FunctionsTable:
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id, db=db)
return FunctionModel.model_validate(function)
else:
return None
except Exception as e:
@@ -381,7 +400,8 @@ class FunctionsTable:
}
)
db.commit()
return self.get_function_by_id(id, db=db)
function = db.get(Function, id)
return FunctionModel.model_validate(function) if function else None
except Exception:
return None
+78 -39
View File
@@ -22,9 +22,9 @@ from sqlalchemy import (
ForeignKey,
cast,
or_,
select,
)
log = logging.getLogger(__name__)
####################
@@ -99,6 +99,16 @@ class GroupResponse(GroupModel):
member_count: Optional[int] = None
class GroupInfoResponse(BaseModel):
id: str
user_id: str
name: str
description: str
member_count: Optional[int] = None
created_at: int
updated_at: int
class GroupForm(BaseModel):
name: str
description: str
@@ -154,7 +164,10 @@ class GroupTable:
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
with get_db_context(db) as db:
query = db.query(Group)
member_count = func.count(GroupMember.user_id).label("member_count")
query = db.query(Group, member_count).outerjoin(
GroupMember, GroupMember.group_id == Group.id
)
if filter:
if "query" in filter:
@@ -165,27 +178,24 @@ class GroupTable:
share_value = filter["share"]
member_id = filter.get("member_id")
json_share = Group.data["config"]["share"]
json_share_bool = json_share.as_boolean()
json_share_str = json_share.as_string()
json_share_lower = func.lower(json_share_str)
if share_value:
# Groups open to anyone: data is null, share is null, or share is true
anyone_can_share = or_(
Group.data.is_(None),
json_share_bool.is_(None),
json_share_bool == True,
json_share_str.is_(None),
json_share_lower == "true",
json_share_lower == "1", # Handle SQLite boolean true
)
if member_id:
# Also include member-only groups where user is a member
member_groups_subq = (
db.query(GroupMember.group_id)
.filter(GroupMember.user_id == member_id)
.subquery()
member_groups_select = select(GroupMember.group_id).where(
GroupMember.user_id == member_id
)
members_only_and_is_member = and_(
json_share_str == "members",
Group.id.in_(member_groups_subq),
json_share_lower == "members",
Group.id.in_(member_groups_select),
)
query = query.filter(
or_(anyone_can_share, members_only_and_is_member)
@@ -194,27 +204,30 @@ class GroupTable:
query = query.filter(anyone_can_share)
else:
query = query.filter(
and_(Group.data.isnot(None), json_share_bool == False)
and_(Group.data.isnot(None), json_share_lower == "false")
)
else:
# Only apply member_id filter when share filter is NOT present
if "member_id" in filter:
query = query.join(
GroupMember, GroupMember.group_id == Group.id
).filter(GroupMember.user_id == filter["member_id"])
query = query.filter(
Group.id.in_(
select(GroupMember.group_id).where(
GroupMember.user_id == filter["member_id"]
)
)
)
results = query.group_by(Group.id).order_by(Group.updated_at.desc()).all()
groups = query.order_by(Group.updated_at.desc()).all()
return [
GroupResponse.model_validate(
{
**GroupModel.model_validate(group).model_dump(),
"member_count": self.get_group_member_count_by_id(
group.id, db=db
),
"member_count": count or 0,
}
)
for group in groups
for group, count in results
]
def search_groups(
@@ -231,29 +244,42 @@ class GroupTable:
if "query" in filter:
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
if "member_id" in filter:
query = query.join(
GroupMember, GroupMember.group_id == Group.id
).filter(GroupMember.user_id == filter["member_id"])
query = query.filter(
Group.id.in_(
select(GroupMember.group_id).where(
GroupMember.user_id == filter["member_id"]
)
)
)
if "share" in filter:
# 'share' is stored in data JSON, support both sqlite and postgres
share_value = filter["share"]
print("Filtering by share:", share_value)
query = query.filter(
Group.data.op("->>")("share") == str(share_value)
)
total = query.count()
query = query.order_by(Group.updated_at.desc())
groups = query.offset(skip).limit(limit).all()
member_count = func.count(GroupMember.user_id).label("member_count")
results = (
query.add_columns(member_count)
.outerjoin(GroupMember, GroupMember.group_id == Group.id)
.group_by(Group.id)
.order_by(Group.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return {
"items": [
GroupResponse.model_validate(
**GroupModel.model_validate(group).model_dump(),
member_count=self.get_group_member_count_by_id(group.id, db=db),
{
**GroupModel.model_validate(group).model_dump(),
"member_count": count or 0,
}
)
for group in groups
for group, count in results
],
"total": total,
}
@@ -304,14 +330,14 @@ class GroupTable:
def get_group_user_ids_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[list[str]]:
) -> list[str]:
with get_db_context(db) as db:
members = (
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
)
if not members:
return None
return []
return [m[0] for m in members]
@@ -368,6 +394,20 @@ class GroupTable:
)
return count if count else 0
def get_group_member_counts_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> dict[str, int]:
if not ids:
return {}
with get_db_context(db) as db:
rows = (
db.query(GroupMember.group_id, func.count(GroupMember.user_id))
.filter(GroupMember.group_id.in_(ids))
.group_by(GroupMember.group_id)
.all()
)
return {group_id: count for group_id, count in rows}
def update_group_by_id(
self,
id: str,
@@ -588,11 +628,10 @@ class GroupTable:
if not user_ids:
return GroupModel.model_validate(group)
# Remove each user from group_member
for user_id in user_ids:
db.query(GroupMember).filter(
GroupMember.group_id == id, GroupMember.user_id == user_id
).delete()
# Remove users from group_member in batch
db.query(GroupMember).filter(
GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)
).delete(synchronize_session=False)
# Update group timestamp
group.updated_at = int(time.time())
+103 -58
View File
@@ -15,9 +15,10 @@ from open_webui.models.files import (
)
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
BigInteger,
Column,
@@ -29,10 +30,6 @@ from sqlalchemy import (
or_,
)
from open_webui.utils.access_control import has_access
from open_webui.utils.db.access_control import has_permission
log = logging.getLogger(__name__)
####################
@@ -50,22 +47,6 @@ class Knowledge(Base):
description = Column(Text)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -82,7 +63,7 @@ class KnowledgeModel(BaseModel):
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -139,7 +120,7 @@ class KnowledgeUserResponse(KnowledgeUserModel):
class KnowledgeForm(BaseModel):
name: str
description: str
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class FileUserResponse(FileModelResponse):
@@ -157,27 +138,47 @@ class KnowledgeFileListResponse(BaseModel):
class KnowledgeTable:
def _get_access_grants(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
def _to_knowledge_model(
self, knowledge: Knowledge, db: Optional[Session] = None
) -> KnowledgeModel:
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
exclude={"access_grants"}
)
knowledge_data["access_grants"] = self._get_access_grants(
knowledge_data["id"], db=db
)
return KnowledgeModel.model_validate(knowledge_data)
def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
with get_db_context(db) as db:
knowledge = KnowledgeModel(
**{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
"access_grants": [],
}
)
try:
result = Knowledge(**knowledge.model_dump())
result = Knowledge(**knowledge.model_dump(exclude={"access_grants"}))
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"knowledge", result.id, form_data.access_grants, db=db
)
if result:
return KnowledgeModel.model_validate(result)
return self._to_knowledge_model(result, db=db)
else:
return None
except Exception:
@@ -201,7 +202,7 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(knowledge).model_dump(),
**self._to_knowledge_model(knowledge, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -229,6 +230,9 @@ class KnowledgeTable:
or_(
Knowledge.name.ilike(f"%{query_key}%"),
Knowledge.description.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
User.username.ilike(f"%{query_key}%"),
)
)
@@ -238,9 +242,16 @@ class KnowledgeTable:
elif view_option == "shared":
query = query.filter(Knowledge.user_id != user_id)
query = has_permission(db, Knowledge, query, filter)
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
)
query = query.order_by(Knowledge.updated_at.desc())
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
total = query.count()
if skip:
@@ -255,8 +266,8 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(
knowledge_base
**self._to_knowledge_model(
knowledge_base, db=db
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
@@ -291,7 +302,14 @@ class KnowledgeTable:
# Apply access-control directly to the joined query
# This makes the database handle filtering, even with 10k+ KBs
query = has_permission(db, Knowledge, query, filter)
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
)
# Apply filename search
if filter:
@@ -300,7 +318,7 @@ class KnowledgeTable:
query = query.filter(File.filename.ilike(f"%{q}%"))
# Order by file changes
query = query.order_by(File.updated_at.desc())
query = query.order_by(File.updated_at.desc(), File.id.asc())
# Count before pagination
total = query.count()
@@ -324,8 +342,8 @@ class KnowledgeTable:
if user
else None
),
collection=KnowledgeModel.model_validate(
knowledge
collection=self._to_knowledge_model(
knowledge, db=db
).model_dump(),
)
)
@@ -347,7 +365,14 @@ class KnowledgeTable:
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
return AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write", db: Optional[Session] = None
@@ -360,8 +385,13 @@ class KnowledgeTable:
knowledge_base
for knowledge_base in knowledge_bases
if knowledge_base.user_id == user_id
or has_access(
user_id, permission, knowledge_base.access_control, user_group_ids
or AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
@@ -371,7 +401,7 @@ class KnowledgeTable:
try:
with get_db_context(db) as db:
knowledge = db.query(Knowledge).filter_by(id=id).first()
return KnowledgeModel.model_validate(knowledge) if knowledge else None
return self._to_knowledge_model(knowledge, db=db) if knowledge else None
except Exception:
return None
@@ -388,7 +418,14 @@ class KnowledgeTable:
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
if AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
user_group_ids=user_group_ids,
db=db,
):
return knowledge
return None
@@ -404,7 +441,8 @@ class KnowledgeTable:
.all()
)
return [
KnowledgeModel.model_validate(knowledge) for knowledge in knowledges
self._to_knowledge_model(knowledge, db=db)
for knowledge in knowledges
]
except Exception:
return []
@@ -427,6 +465,9 @@ class KnowledgeTable:
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
# Default sort: updated_at descending
primary_sort = File.updated_at.desc()
if filter:
query_key = filter.get("query")
if query_key:
@@ -440,27 +481,23 @@ class KnowledgeTable:
order_by = filter.get("order_by")
direction = filter.get("direction")
is_asc = direction == "asc"
if order_by == "name":
if direction == "asc":
query = query.order_by(File.filename.asc())
else:
query = query.order_by(File.filename.desc())
primary_sort = (
File.filename.asc() if is_asc else File.filename.desc()
)
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(File.created_at.asc())
else:
query = query.order_by(File.created_at.desc())
primary_sort = (
File.created_at.asc() if is_asc else File.created_at.desc()
)
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(File.updated_at.asc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
primary_sort = (
File.updated_at.asc() if is_asc else File.updated_at.desc()
)
else:
query = query.order_by(File.updated_at.desc())
# Apply sort with secondary key for deterministic pagination
query = query.order_by(primary_sort, File.id.asc())
# Count BEFORE pagination
total = query.count()
@@ -595,11 +632,15 @@ class KnowledgeTable:
knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update(
{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"updated_at": int(time.time()),
}
)
db.commit()
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"knowledge", id, form_data.access_grants, db=db
)
return self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
@@ -626,6 +667,7 @@ class KnowledgeTable:
def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("knowledge", id, db=db)
db.query(Knowledge).filter_by(id=id).delete()
db.commit()
return True
@@ -635,6 +677,9 @@ class KnowledgeTable:
def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()]
for knowledge_id in knowledge_ids:
AccessGrants.revoke_all_access("knowledge", knowledge_id, db=db)
db.query(Knowledge).delete()
db.commit()
+2 -1
View File
@@ -82,7 +82,8 @@ class MemoriesTable:
memory.updated_at = int(time.time())
db.commit()
return self.get_memory_by_id(id)
db.refresh(memory)
return MemoryModel.model_validate(memory)
except Exception:
return None
+81 -97
View File
@@ -7,19 +7,16 @@ from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import String, cast, or_, and_, func
from sqlalchemy.dialects import postgresql, sqlite
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import has_access
from sqlalchemy import BigInteger, Column, Text, Boolean
log = logging.getLogger(__name__)
@@ -80,23 +77,6 @@ class Model(Base):
Holds a JSON encoded blob of metadata, see `ModelMeta`.
"""
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
is_active = Column(Boolean, default=True)
updated_at = Column(BigInteger)
@@ -112,7 +92,7 @@ class ModelModel(BaseModel):
params: ModelParams
meta: ModelMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
is_active: bool
updated_at: int # timestamp in epoch
@@ -154,31 +134,45 @@ class ModelForm(BaseModel):
name: str
meta: ModelMeta
params: ModelParams
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
is_active: bool = True
class ModelsTable:
def _get_access_grants(
self, model_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
model_data = ModelModel.model_validate(model).model_dump(
exclude={"access_grants"}
)
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
return ModelModel.model_validate(model_data)
def insert_new_model(
self, form_data: ModelForm, user_id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
model = ModelModel(
**{
**form_data.model_dump(),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
try:
with get_db_context(db) as db:
result = Model(**model.model_dump())
result = Model(
**{
**form_data.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"model", result.id, form_data.access_grants, db=db
)
if result:
return ModelModel.model_validate(result)
return self._to_model_model(result, db=db)
else:
return None
except Exception as e:
@@ -187,7 +181,9 @@ class ModelsTable:
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
return [ModelModel.model_validate(model) for model in db.query(Model).all()]
return [
self._to_model_model(model, db=db) for model in db.query(Model).all()
]
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
with get_db_context(db) as db:
@@ -204,7 +200,7 @@ class ModelsTable:
models.append(
ModelUserResponse.model_validate(
{
**ModelModel.model_validate(model).model_dump(),
**self._to_model_model(model, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -214,7 +210,7 @@ class ModelsTable:
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
return [
ModelModel.model_validate(model)
self._to_model_model(model, db=db)
for model in db.query(Model).filter(Model.base_model_id == None).all()
]
@@ -229,50 +225,25 @@ class ModelsTable:
model
for model in models
if model.user_id == user_id
or has_access(user_id, permission, model.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="model",
resource_id=model.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
# Public access
conditions = []
if group_ids or user_id:
conditions.extend(
[
Model.access_control.is_(None),
cast(Model.access_control, String) == "null",
]
)
# User-level permission
if user_id:
conditions.append(Model.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Model.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Model.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Model,
filter=filter,
resource_type="model",
permission=permission,
)
def search_models(
self,
@@ -294,6 +265,9 @@ class ModelsTable:
or_(
Model.name.ilike(f"%{query_key}%"),
Model.base_model_id.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
User.username.ilike(f"%{query_key}%"),
)
)
@@ -355,7 +329,7 @@ class ModelsTable:
for model, user in items:
models.append(
ModelUserResponse(
**ModelModel.model_validate(model).model_dump(),
**self._to_model_model(model, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -372,7 +346,7 @@ class ModelsTable:
try:
with get_db_context(db) as db:
model = db.get(Model, id)
return ModelModel.model_validate(model)
return self._to_model_model(model, db=db) if model else None
except Exception:
return None
@@ -382,7 +356,7 @@ class ModelsTable:
try:
with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
return [ModelModel.model_validate(model) for model in models]
return [self._to_model_model(model, db=db) for model in models]
except Exception:
return []
@@ -391,17 +365,16 @@ class ModelsTable:
) -> Optional[ModelModel]:
with get_db_context(db) as db:
try:
is_active = db.query(Model).filter_by(id=id).first().is_active
model = db.query(Model).filter_by(id=id).first()
if not model:
return None
db.query(Model).filter_by(id=id).update(
{
"is_active": not is_active,
"updated_at": int(time.time()),
}
)
model.is_active = not model.is_active
model.updated_at = int(time.time())
db.commit()
db.refresh(model)
return self.get_model_by_id(id, db=db)
return self._to_model_model(model, db=db)
except Exception:
return None
@@ -411,14 +384,16 @@ class ModelsTable:
try:
with get_db_context(db) as db:
# update only the fields that are present in the model
data = model.model_dump(exclude={"id"})
data = model.model_dump(exclude={"id", "access_grants"})
result = db.query(Model).filter_by(id=id).update(data)
db.commit()
if model.access_grants is not None:
AccessGrants.set_access_grants(
"model", id, model.access_grants, db=db
)
model = db.get(Model, id)
db.refresh(model)
return ModelModel.model_validate(model)
return self.get_model_by_id(id, db=db)
except Exception as e:
log.exception(f"Failed to update the model by id {id}: {e}")
return None
@@ -426,6 +401,7 @@ class ModelsTable:
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("model", id, db=db)
db.query(Model).filter_by(id=id).delete()
db.commit()
@@ -436,6 +412,9 @@ class ModelsTable:
def delete_all_models(self, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
model_ids = [row[0] for row in db.query(Model.id).all()]
for model_id in model_ids:
AccessGrants.revoke_all_access("model", model_id, db=db)
db.query(Model).delete()
db.commit()
@@ -460,7 +439,7 @@ class ModelsTable:
if model.id in existing_ids:
db.query(Model).filter_by(id=model.id).update(
{
**model.model_dump(),
**model.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
}
@@ -468,22 +447,27 @@ class ModelsTable:
else:
new_model = Model(
**{
**model.model_dump(),
**model.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
db.add(new_model)
AccessGrants.set_access_grants(
"model", model.id, model.access_grants, db=db
)
# Remove models that are no longer present
for model in existing_models:
if model.id not in new_model_ids:
AccessGrants.revoke_all_access("model", model.id, db=db)
db.delete(model)
db.commit()
return [
ModelModel.model_validate(model) for model in db.query(Model).all()
self._to_model_model(model, db=db)
for model in db.query(Model).all()
]
except Exception as e:
log.exception(f"Error syncing models for user {user_id}: {e}")
+42 -138
View File
@@ -7,17 +7,13 @@ from functools import lru_cache
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
from sqlalchemy.sql import exists
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, Text, JSON
from sqlalchemy import or_, func, cast
####################
# Note DB Schema
@@ -34,8 +30,6 @@ class Note(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -50,7 +44,7 @@ class NoteModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -65,14 +59,14 @@ class NoteForm(BaseModel):
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class NoteUpdateForm(BaseModel):
title: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class NoteUserResponse(NoteModel):
@@ -94,122 +88,25 @@ class NoteListResponse(BaseModel):
class NoteTable:
def _get_access_grants(
self, note_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
return NoteModel.model_validate(note_data)
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
conditions = []
# Handle read_only permission separately
if permission == "read_only":
# For read_only, we want items where:
# 1. User has explicit read permission (via groups or user-level)
# 2. BUT does NOT have write permission
# 3. Public items are NOT considered read_only
read_conditions = []
# Group-level read permission
if group_ids:
group_read_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_read_conditions.append(
Note.access_control["read"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_read_conditions.append(
cast(
Note.access_control["read"]["group_ids"],
JSONB,
).contains([gid])
)
if group_read_conditions:
read_conditions.append(or_(*group_read_conditions))
# Combine read conditions
if read_conditions:
has_read = or_(*read_conditions)
else:
# If no read conditions, return empty result
return query.filter(False)
# Now exclude items where user has write permission
write_exclusions = []
# Exclude items owned by user (they have implicit write)
if user_id:
write_exclusions.append(Note.user_id != user_id)
# Exclude items where user has explicit write permission via groups
if group_ids:
group_write_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_write_conditions.append(
Note.access_control["write"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_write_conditions.append(
cast(
Note.access_control["write"]["group_ids"],
JSONB,
).contains([gid])
)
if group_write_conditions:
# User should NOT have write permission
write_exclusions.append(~or_(*group_write_conditions))
# Exclude public items (items without access_control)
write_exclusions.append(Note.access_control.isnot(None))
write_exclusions.append(cast(Note.access_control, String) != "null")
# Combine: has read AND does not have write AND not public
if write_exclusions:
query = query.filter(and_(has_read, *write_exclusions))
else:
query = query.filter(has_read)
return query
# Original logic for other permissions (read, write, etc.)
# Public access conditions
if group_ids or user_id:
conditions.extend(
[
Note.access_control.is_(None),
cast(Note.access_control, String) == "null",
]
)
# User-level permission (owner has all permissions)
if user_id:
conditions.append(Note.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Note.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Note.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Note,
filter=filter,
resource_type="note",
permission=permission,
)
def insert_new_note(
self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
@@ -219,17 +116,21 @@ class NoteTable:
**{
"id": str(uuid.uuid4()),
"user_id": user_id,
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
"access_grants": [],
}
)
new_note = Note(**note.model_dump())
new_note = Note(**note.model_dump(exclude={"access_grants"}))
db.add(new_note)
db.commit()
return note
AccessGrants.set_access_grants(
"note", note.id, form_data.access_grants, db=db
)
return self._to_note_model(new_note, db=db)
def get_notes(
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
@@ -241,7 +142,7 @@ class NoteTable:
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
return [self._to_note_model(note, db=db) for note in notes]
def search_notes(
self,
@@ -330,7 +231,7 @@ class NoteTable:
for note, user in items:
notes.append(
NoteUserResponse(
**NoteModel.model_validate(note).model_dump(),
**self._to_note_model(note, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -365,14 +266,14 @@ class NoteTable:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
return [self._to_note_model(note, db=db) for note in notes]
def get_note_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[NoteModel]:
with get_db_context(db) as db:
note = db.query(Note).filter(Note.id == id).first()
return NoteModel.model_validate(note) if note else None
return self._to_note_model(note, db=db) if note else None
def update_note_by_id(
self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
@@ -391,17 +292,20 @@ class NoteTable:
if "meta" in form_data:
note.meta = {**note.meta, **form_data["meta"]}
if "access_control" in form_data:
note.access_control = form_data["access_control"]
if "access_grants" in form_data:
AccessGrants.set_access_grants(
"note", id, form_data["access_grants"], db=db
)
note.updated_at = int(time.time_ns())
db.commit()
return NoteModel.model_validate(note) if note else None
return self._to_note_model(note, db=db) if note else None
def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("note", id, db=db)
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True
+10 -3
View File
@@ -102,7 +102,7 @@ class OAuthSessionTable:
decrypted = self.fernet.decrypt(token.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting tokens: {e}")
log.error(f"Error decrypting tokens: {type(e).__name__}: {e}")
raise
def create_session(
@@ -209,8 +209,15 @@ class OAuthSessionTable:
results = []
for session in sessions:
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
try:
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
except Exception as e:
log.warning(
f"Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}"
)
db.query(OAuthSession).filter_by(id=session.id).delete()
db.commit()
return results
+234
View File
@@ -0,0 +1,234 @@
"""Prompt history model for version tracking."""
import time
import uuid
from typing import Optional
import json
import difflib
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Index
####################
# PromptHistory DB Schema
####################
class PromptHistory(Base):
__tablename__ = "prompt_history"
id = Column(Text, primary_key=True)
prompt_id = Column(Text, nullable=False, index=True)
parent_id = Column(Text, nullable=True) # Reference to parent commit
snapshot = Column(JSON, nullable=False)
user_id = Column(Text, nullable=False)
commit_message = Column(Text, nullable=True)
created_at = Column(BigInteger, nullable=False)
class PromptHistoryModel(BaseModel):
id: str
prompt_id: str
parent_id: Optional[str] = None
snapshot: dict
user_id: str
commit_message: Optional[str] = None
created_at: int
model_config = ConfigDict(from_attributes=True)
class PromptHistoryResponse(PromptHistoryModel):
"""Response model with user info."""
user: Optional[UserResponse] = None
class PromptHistoryTable:
def create_history_entry(
self,
prompt_id: str,
snapshot: dict,
user_id: str,
parent_id: Optional[str] = None,
commit_message: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Create a new history entry (commit) for a prompt."""
with get_db_context(db) as db:
history = PromptHistory(
id=str(uuid.uuid4()),
prompt_id=prompt_id,
parent_id=parent_id,
snapshot=snapshot,
user_id=user_id,
commit_message=commit_message,
created_at=int(time.time()),
)
db.add(history)
db.commit()
db.refresh(history)
return PromptHistoryModel.model_validate(history)
def get_history_by_prompt_id(
self,
prompt_id: str,
limit: int = 50,
offset: int = 0,
db: Optional[Session] = None,
) -> list[PromptHistoryResponse]:
"""Get all history entries for a prompt, ordered by created_at desc."""
with get_db_context(db) as db:
entries = (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.order_by(PromptHistory.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
# Get user info for each entry
user_ids = list(set(e.user_id for e in entries))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
return [
PromptHistoryResponse(
**PromptHistoryModel.model_validate(entry).model_dump(),
user=(
users_dict.get(entry.user_id).model_dump()
if users_dict.get(entry.user_id)
else None
),
)
for entry in entries
]
def get_history_entry_by_id(
self,
history_id: str,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Get a specific history entry by ID."""
with get_db_context(db) as db:
entry = (
db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
)
if entry:
return PromptHistoryModel.model_validate(entry)
return None
def get_latest_history_entry(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Get the most recent history entry for a prompt."""
with get_db_context(db) as db:
entry = (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.order_by(PromptHistory.created_at.desc())
.first()
)
if entry:
return PromptHistoryModel.model_validate(entry)
return None
def get_history_count(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> int:
"""Get the number of history entries for a prompt."""
with get_db_context(db) as db:
return (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.count()
)
def compute_diff(
self,
from_id: str,
to_id: str,
db: Optional[Session] = None,
) -> Optional[dict]:
"""Compute diff between two history entries."""
with get_db_context(db) as db:
from_entry = (
db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
)
to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first()
if not from_entry or not to_entry:
return None
from_snapshot = from_entry.snapshot
to_snapshot = to_entry.snapshot
# Compute diff for content field
from_content = from_snapshot.get("content", "")
to_content = to_snapshot.get("content", "")
diff_lines = list(
difflib.unified_diff(
from_content.splitlines(keepends=True),
to_content.splitlines(keepends=True),
fromfile=f"v{from_id[:8]}",
tofile=f"v{to_id[:8]}",
lineterm="",
)
)
return {
"from_id": from_id,
"to_id": to_id,
"from_snapshot": from_snapshot,
"to_snapshot": to_snapshot,
"content_diff": diff_lines,
"name_changed": from_snapshot.get("name") != to_snapshot.get("name"),
}
def delete_history_by_prompt_id(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> bool:
"""Delete all history entries for a prompt."""
with get_db_context(db) as db:
db.query(PromptHistory).filter(
PromptHistory.prompt_id == prompt_id
).delete()
db.commit()
return True
def delete_history_entry(
self,
history_id: str,
db: Optional[Session] = None,
) -> bool:
"""Delete a history entry and reparent its children to grandparent."""
with get_db_context(db) as db:
entry = db.query(PromptHistory).filter_by(id=history_id).first()
if not entry:
return False
# Find children that reference this entry as parent
children = db.query(PromptHistory).filter_by(parent_id=history_id).all()
# Reparent children to grandparent
for child in children:
child.parent_id = entry.parent_id
db.delete(entry)
db.commit()
return True
PromptHistories = PromptHistoryTable()
+504 -47
View File
@@ -1,15 +1,17 @@
import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from open_webui.models.prompt_history import PromptHistories
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
####################
# Prompts DB Schema
@@ -19,38 +21,35 @@ from open_webui.utils.access_control import has_access
class Prompt(Base):
__tablename__ = "prompt"
command = Column(String, primary_key=True)
id = Column(Text, primary_key=True)
command = Column(String, unique=True, index=True)
user_id = Column(String)
title = Column(Text)
name = Column(Text)
content = Column(Text)
timestamp = Column(BigInteger)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
tags = Column(JSON, nullable=True)
is_active = Column(Boolean, default=True)
version_id = Column(Text, nullable=True) # Points to active history entry
created_at = Column(BigInteger, nullable=True)
updated_at = Column(BigInteger, nullable=True)
class PromptModel(BaseModel):
id: Optional[str] = None
command: str
user_id: str
title: str
name: str
content: str
timestamp: int # timestamp in epoch
data: Optional[dict] = None
meta: Optional[dict] = None
tags: Optional[list[str]] = None
is_active: Optional[bool] = True
version_id: Optional[str] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
access_control: Optional[dict] = None
model_config = ConfigDict(from_attributes=True)
@@ -67,51 +66,144 @@ class PromptAccessResponse(PromptUserResponse):
write_access: Optional[bool] = False
class PromptListResponse(BaseModel):
items: list[PromptUserResponse]
total: int
class PromptAccessListResponse(BaseModel):
items: list[PromptAccessResponse]
total: int
class PromptForm(BaseModel):
command: str
title: str
name: str # Changed from title
content: str
access_control: Optional[dict] = None
data: Optional[dict] = None
meta: Optional[dict] = None
tags: Optional[list[str]] = None
access_grants: Optional[list[dict]] = None
version_id: Optional[str] = None # Active version
commit_message: Optional[str] = None # For history tracking
is_production: Optional[bool] = True # Whether to set new version as production
class PromptsTable:
def _get_access_grants(
self, prompt_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
def _to_prompt_model(
self, prompt: Prompt, db: Optional[Session] = None
) -> PromptModel:
prompt_data = PromptModel.model_validate(prompt).model_dump(
exclude={"access_grants"}
)
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
return PromptModel.model_validate(prompt_data)
def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]:
now = int(time.time())
prompt_id = str(uuid.uuid4())
prompt = PromptModel(
**{
"user_id": user_id,
**form_data.model_dump(),
"timestamp": int(time.time()),
}
id=prompt_id,
user_id=user_id,
command=form_data.command,
name=form_data.name,
content=form_data.content,
data=form_data.data or {},
meta=form_data.meta or {},
tags=form_data.tags or [],
access_grants=[],
is_active=True,
created_at=now,
updated_at=now,
)
try:
with get_db_context(db) as db:
result = Prompt(**prompt.model_dump())
result = Prompt(**prompt.model_dump(exclude={"access_grants"}))
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"prompt", prompt_id, form_data.access_grants, db=db
)
if result:
return PromptModel.model_validate(result)
current_access_grants = self._get_access_grants(prompt_id, db=db)
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": form_data.command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"tags": form_data.tags or [],
"access_grants": [
grant.model_dump() for grant in current_access_grants
],
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt_id,
snapshot=snapshot,
user_id=user_id,
parent_id=None, # Initial commit has no parent
commit_message=form_data.commit_message or "Initial version",
db=db,
)
# Set the initial version as the production version
if history_entry:
result.version_id = history_entry.id
db.commit()
db.refresh(result)
return self._to_prompt_model(result, db=db)
else:
return None
except Exception:
return None
def get_prompt_by_id(
self, prompt_id: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
"""Get prompt by UUID."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
return self._to_prompt_model(prompt, db=db)
return None
except Exception:
return None
def get_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
return PromptModel.model_validate(prompt)
if prompt:
return self._to_prompt_model(prompt, db=db)
return None
except Exception:
return None
def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]:
with get_db_context(db) as db:
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
all_prompts = (
db.query(Prompt)
.filter(Prompt.is_active == True)
.order_by(Prompt.updated_at.desc())
.all()
)
user_ids = list(set(prompt.user_id for prompt in all_prompts))
@@ -124,7 +216,7 @@ class PromptsTable:
prompts.append(
PromptUserResponse.model_validate(
{
**PromptModel.model_validate(prompt).model_dump(),
**self._to_prompt_model(prompt, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -144,35 +236,400 @@ class PromptsTable:
prompt
for prompt in prompts
if prompt.user_id == user_id
or has_access(user_id, permission, prompt.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="prompt",
resource_id=prompt.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def search_prompts(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> PromptListResponse:
with get_db_context(db) as db:
from open_webui.models.users import User, UserModel
# Join with User table for user filtering and sorting
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = query.filter(Prompt.is_active == True)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Prompt.name.ilike(f"%{query_key}%"),
Prompt.command.ilike(f"%{query_key}%"),
Prompt.content.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Prompt.user_id == user_id)
elif view_option == "shared":
query = query.filter(Prompt.user_id != user_id)
# Apply access grant filtering
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Prompt,
filter=filter,
resource_type="prompt",
permission="read",
)
tag = filter.get("tag")
if tag:
# Search for tag in JSON array field
like_pattern = f'%"{tag.lower()}"%'
tags_text = func.lower(cast(Prompt.tags, String))
query = query.filter(tags_text.like(like_pattern))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Prompt.name.asc())
else:
query = query.order_by(Prompt.name.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Prompt.created_at.asc())
else:
query = query.order_by(Prompt.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Prompt.updated_at.asc())
else:
query = query.order_by(Prompt.updated_at.desc())
else:
query = query.order_by(Prompt.updated_at.desc())
else:
query = query.order_by(Prompt.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
prompts = []
for prompt, user in items:
prompts.append(
PromptUserResponse(
**self._to_prompt_model(prompt, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return PromptListResponse(items=prompts, total=total)
def update_prompt_by_command(
self, command: str, form_data: PromptForm, db: Optional[Session] = None
self,
command: str,
form_data: PromptForm,
user_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
prompt.title = form_data.title
if not prompt:
return None
latest_history = PromptHistories.get_latest_history_entry(
prompt.id, db=db
)
parent_id = latest_history.id if latest_history else None
current_access_grants = self._get_access_grants(prompt.id, db=db)
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.content != form_data.content
or form_data.access_grants is not None
)
# Update prompt fields
prompt.name = form_data.name
prompt.content = form_data.content
prompt.access_control = form_data.access_control
prompt.timestamp = int(time.time())
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
prompt.updated_at = int(time.time())
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"prompt", prompt.id, form_data.access_grants, db=db
)
current_access_grants = self._get_access_grants(prompt.id, db=db)
db.commit()
return PromptModel.model_validate(prompt)
# Create history entry only if content changed
if content_changed:
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"access_grants": [
grant.model_dump() for grant in current_access_grants
],
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt.id,
snapshot=snapshot,
user_id=user_id,
parent_id=parent_id,
commit_message=form_data.commit_message,
db=db,
)
# Set as production if flag is True (default)
if form_data.is_production and history_entry:
prompt.version_id = history_entry.id
db.commit()
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
def update_prompt_by_id(
self,
prompt_id: str,
form_data: PromptForm,
user_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
latest_history = PromptHistories.get_latest_history_entry(
prompt.id, db=db
)
parent_id = latest_history.id if latest_history else None
current_access_grants = self._get_access_grants(prompt.id, db=db)
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.command != form_data.command
or prompt.content != form_data.content
or form_data.access_grants is not None
or (form_data.tags is not None and prompt.tags != form_data.tags)
)
# Update prompt fields
prompt.name = form_data.name
prompt.command = form_data.command
prompt.content = form_data.content
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
if form_data.tags is not None:
prompt.tags = form_data.tags
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"prompt", prompt.id, form_data.access_grants, db=db
)
current_access_grants = self._get_access_grants(prompt.id, db=db)
prompt.updated_at = int(time.time())
db.commit()
# Create history entry only if content changed
if content_changed:
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": prompt.command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"tags": prompt.tags or [],
"access_grants": [
grant.model_dump() for grant in current_access_grants
],
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt.id,
snapshot=snapshot,
user_id=user_id,
parent_id=parent_id,
commit_message=form_data.commit_message,
db=db,
)
# Set as production if flag is True (default)
if form_data.is_production and history_entry:
prompt.version_id = history_entry.id
db.commit()
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
def update_prompt_metadata(
self,
prompt_id: str,
name: str,
command: str,
tags: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
"""Update only name, command, and tags (no history created)."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
prompt.name = name
prompt.command = command
if tags is not None:
prompt.tags = tags
prompt.updated_at = int(time.time())
db.commit()
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
def update_prompt_version(
self,
prompt_id: str,
version_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
"""Set the active version of a prompt and restore content from that version's snapshot."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
history_entry = PromptHistories.get_history_entry_by_id(
version_id, db=db
)
if not history_entry:
return None
# Restore prompt content from the snapshot
snapshot = history_entry.snapshot
if snapshot:
prompt.name = snapshot.get("name", prompt.name)
prompt.content = snapshot.get("content", prompt.content)
prompt.data = snapshot.get("data", prompt.data)
prompt.meta = snapshot.get("meta", prompt.meta)
prompt.tags = snapshot.get("tags", prompt.tags)
# Note: command and access_grants are not restored from snapshot
prompt.version_id = version_id
prompt.updated_at = int(time.time())
db.commit()
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try:
with get_db_context(db) as db:
db.query(Prompt).filter_by(command=command).delete()
db.commit()
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
return True
prompt.is_active = False
prompt.updated_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.updated_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def hard_delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Permanently delete a prompt and its history."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
# Delete prompt
db.query(Prompt).filter_by(command=command).delete()
db.commit()
return True
return False
except Exception:
return False
def get_tags(self, db: Optional[Session] = None) -> list[str]:
try:
with get_db_context(db) as db:
prompts = db.query(Prompt).filter_by(is_active=True).all()
tags = set()
for prompt in prompts:
if prompt.tags:
for tag in prompt.tags:
if tag:
tags.add(tag)
return sorted(list(tags))
except Exception:
return []
Prompts = PromptsTable()
+339
View File
@@ -0,0 +1,339 @@
import logging
import time
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, or_
log = logging.getLogger(__name__)
####################
# Skills DB Schema
####################
class Skill(Base):
__tablename__ = "skill"
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
name = Column(Text, unique=True)
description = Column(Text, nullable=True)
content = Column(Text)
meta = Column(JSON)
is_active = Column(Boolean, default=True)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
class SkillMeta(BaseModel):
tags: Optional[list[str]] = []
class SkillModel(BaseModel):
id: str
user_id: str
name: str
description: Optional[str] = None
content: str
meta: SkillMeta
is_active: bool = True
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class SkillUserModel(SkillModel):
user: Optional[UserResponse] = None
class SkillResponse(BaseModel):
id: str
user_id: str
name: str
description: Optional[str] = None
meta: SkillMeta
is_active: bool = True
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
class SkillUserResponse(SkillResponse):
user: Optional[UserResponse] = None
model_config = ConfigDict(extra="allow")
class SkillAccessResponse(SkillUserResponse):
write_access: Optional[bool] = False
class SkillForm(BaseModel):
id: str
name: str
description: Optional[str] = None
content: str
meta: SkillMeta = SkillMeta()
is_active: bool = True
access_grants: Optional[list[dict]] = None
class SkillListResponse(BaseModel):
items: list[SkillUserResponse] = []
total: int = 0
class SkillAccessListResponse(BaseModel):
items: list[SkillAccessResponse] = []
total: int = 0
class SkillsTable:
def _get_access_grants(
self, skill_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("skill", skill_id, db=db)
def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel:
skill_data = SkillModel.model_validate(skill).model_dump(
exclude={"access_grants"}
)
skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db)
return SkillModel.model_validate(skill_data)
def insert_new_skill(
self,
user_id: str,
form_data: SkillForm,
db: Optional[Session] = None,
) -> Optional[SkillModel]:
with get_db_context(db) as db:
try:
result = Skill(
**{
**form_data.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"skill", result.id, form_data.access_grants, db=db
)
if result:
return self._to_skill_model(result, db=db)
else:
return None
except Exception as e:
log.exception(f"Error creating a new skill: {e}")
return None
def get_skill_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[SkillModel]:
try:
with get_db_context(db) as db:
skill = db.get(Skill, id)
return self._to_skill_model(skill, db=db) if skill else None
except Exception:
return None
def get_skill_by_name(
self, name: str, db: Optional[Session] = None
) -> Optional[SkillModel]:
try:
with get_db_context(db) as db:
skill = db.query(Skill).filter_by(name=name).first()
return self._to_skill_model(skill, db=db) if skill else None
except Exception:
return None
def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]:
with get_db_context(db) as db:
all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all()
user_ids = list(set(skill.user_id for skill in all_skills))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
skills = []
for skill in all_skills:
user = users_dict.get(skill.user_id)
skills.append(
SkillUserModel.model_validate(
{
**self._to_skill_model(skill, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
)
return skills
def get_skills_by_user_id(
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[SkillUserModel]:
skills = self.get_skills(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
skill
for skill in skills
if skill.user_id == user_id
or AccessGrants.has_access(
user_id=user_id,
resource_type="skill",
resource_id=skill.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def search_skills(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> SkillListResponse:
try:
with get_db_context(db) as db:
from open_webui.models.users import User, UserModel
# Join with User table for user filtering
query = db.query(Skill, User).outerjoin(User, User.id == Skill.user_id)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Skill.name.ilike(f"%{query_key}%"),
Skill.description.ilike(f"%{query_key}%"),
Skill.id.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Skill.user_id == user_id)
elif view_option == "shared":
query = query.filter(Skill.user_id != user_id)
# Apply access grant filtering
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Skill,
filter=filter,
resource_type="skill",
permission="read",
)
query = query.order_by(Skill.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
skills = []
for skill, user in items:
skills.append(
SkillUserResponse(
**self._to_skill_model(skill, db=db).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return SkillListResponse(items=skills, total=total)
except Exception as e:
log.exception(f"Error searching skills: {e}")
return SkillListResponse(items=[], total=0)
def update_skill_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[SkillModel]:
try:
with get_db_context(db) as db:
access_grants = updated.pop("access_grants", None)
db.query(Skill).filter_by(id=id).update(
{**updated, "updated_at": int(time.time())}
)
db.commit()
if access_grants is not None:
AccessGrants.set_access_grants("skill", id, access_grants, db=db)
skill = db.query(Skill).get(id)
db.refresh(skill)
return self._to_skill_model(skill, db=db)
except Exception:
return None
def toggle_skill_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[SkillModel]:
with get_db_context(db) as db:
try:
skill = db.query(Skill).filter_by(id=id).first()
if not skill:
return None
skill.is_active = not skill.is_active
skill.updated_at = int(time.time())
db.commit()
db.refresh(skill)
return self._to_skill_model(skill, db=db)
except Exception:
return None
def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("skill", id, db=db)
db.query(Skill).filter_by(id=id).delete()
db.commit()
return True
except Exception:
return False
Skills = SkillsTable()
+40
View File
@@ -115,5 +115,45 @@ class TagTable:
log.error(f"delete_tag: {e}")
return False
def delete_tags_by_ids_and_user_id(
self, ids: list[str], user_id: str, db: Optional[Session] = None
) -> bool:
"""Delete all tags whose id is in *ids* for the given user, in one query."""
if not ids:
return True
try:
with get_db_context(db) as db:
db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).delete(
synchronize_session=False
)
db.commit()
return True
except Exception as e:
log.error(f"delete_tags_by_ids: {e}")
return False
def ensure_tags_exist(
self, names: list[str], user_id: str, db: Optional[Session] = None
) -> None:
"""Create tag rows for any *names* that don't already exist for *user_id*."""
if not names:
return
ids = [n.replace(" ", "_").lower() for n in names]
with get_db_context(db) as db:
existing = {
t.id
for t in db.query(Tag.id)
.filter(Tag.id.in_(ids), Tag.user_id == user_id)
.all()
}
new_tags = [
Tag(id=tag_id, name=name, user_id=user_id)
for tag_id, name in zip(ids, names)
if tag_id not in existing
]
if new_tags:
db.add_all(new_tags)
db.commit()
Tags = TagTable()
+44 -41
View File
@@ -6,12 +6,10 @@ from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, String, Text
log = logging.getLogger(__name__)
@@ -31,23 +29,6 @@ class Tool(Base):
meta = Column(JSONField)
valves = Column(JSONField)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
@@ -64,7 +45,7 @@ class ToolModel(BaseModel):
content: str
specs: list[dict]
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@@ -86,7 +67,7 @@ class ToolResponse(BaseModel):
user_id: str
name: str
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@@ -106,7 +87,7 @@ class ToolForm(BaseModel):
name: str
content: str
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class ToolValves(BaseModel):
@@ -114,6 +95,16 @@ class ToolValves(BaseModel):
class ToolsTable:
def _get_access_grants(
self, tool_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
return ToolModel.model_validate(tool_data)
def insert_new_tool(
self,
user_id: str,
@@ -122,23 +113,24 @@ class ToolsTable:
db: Optional[Session] = None,
) -> Optional[ToolModel]:
with get_db_context(db) as db:
tool = ToolModel(
**{
**form_data.model_dump(),
"specs": specs,
"user_id": user_id,
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
try:
result = Tool(**tool.model_dump())
result = Tool(
**{
**form_data.model_dump(exclude={"access_grants"}),
"specs": specs,
"user_id": user_id,
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"tool", result.id, form_data.access_grants, db=db
)
if result:
return ToolModel.model_validate(result)
return self._to_tool_model(result, db=db)
else:
return None
except Exception as e:
@@ -151,7 +143,7 @@ class ToolsTable:
try:
with get_db_context(db) as db:
tool = db.get(Tool, id)
return ToolModel.model_validate(tool)
return self._to_tool_model(tool, db=db) if tool else None
except Exception:
return None
@@ -170,7 +162,7 @@ class ToolsTable:
tools.append(
ToolUserModel.model_validate(
{
**ToolModel.model_validate(tool).model_dump(),
**self._to_tool_model(tool, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -189,7 +181,14 @@ class ToolsTable:
tool
for tool in tools
if tool.user_id == user_id
or has_access(user_id, permission, tool.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="tool",
resource_id=tool.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def get_tool_valves_by_id(
@@ -266,20 +265,24 @@ class ToolsTable:
) -> Optional[ToolModel]:
try:
with get_db_context(db) as db:
access_grants = updated.pop("access_grants", None)
db.query(Tool).filter_by(id=id).update(
{**updated, "updated_at": int(time.time())}
)
db.commit()
if access_grants is not None:
AccessGrants.set_access_grants("tool", id, access_grants, db=db)
tool = db.query(Tool).get(id)
db.refresh(tool)
return ToolModel.model_validate(tool)
return self._to_tool_model(tool, db=db)
except Exception:
return None
def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("tool", id, db=db)
db.query(Tool).filter_by(id=id).delete()
db.commit()
+132 -29
View File
@@ -1,7 +1,7 @@
import time
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, defer
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
@@ -12,9 +12,10 @@ from open_webui.models.groups import Groups, GroupMember
from open_webui.models.channels import ChannelMember
from open_webui.utils.misc import throttle
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import (
BigInteger,
JSON,
@@ -27,7 +28,7 @@ from sqlalchemy import (
select,
cast,
)
from sqlalchemy import or_, case
from sqlalchemy import or_, case, func
from sqlalchemy.dialects.postgresql import JSONB
import datetime
@@ -70,6 +71,7 @@ class User(Base):
settings = Column(JSON, nullable=True)
oauth = Column(JSON, nullable=True)
scim = Column(JSON, nullable=True)
last_active_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -85,7 +87,7 @@ class UserModel(BaseModel):
name: str
profile_image_url: str
profile_image_url: Optional[str] = None
profile_banner_image_url: Optional[str] = None
bio: Optional[str] = None
@@ -102,6 +104,7 @@ class UserModel(BaseModel):
settings: Optional[UserSettings] = None
oauth: Optional[dict] = None
scim: Optional[dict] = None
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -109,6 +112,12 @@ class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="after")
def set_profile_image_url(self):
if not self.profile_image_url:
self.profile_image_url = f"/api/v1/users/{self.id}/profile/image"
return self
class UserStatusModel(UserModel):
is_active: bool = False
@@ -154,6 +163,11 @@ class UpdateProfileForm(BaseModel):
gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = None
@field_validator("profile_image_url")
@classmethod
def check_profile_image_url(cls, v: str) -> str:
return validate_profile_image_url(v)
class UserGroupIdsModel(UserModel):
group_ids: list[str] = []
@@ -184,6 +198,9 @@ class UserInfoResponse(UserStatus):
name: str
email: str
role: str
bio: Optional[str] = None
groups: Optional[list] = []
is_active: bool = False
class UserIdNameResponse(BaseModel):
@@ -234,6 +251,11 @@ class UserUpdateForm(BaseModel):
profile_image_url: str
password: Optional[str] = None
@field_validator("profile_image_url")
@classmethod
def check_profile_image_url(cls, v: str) -> str:
return validate_profile_image_url(v)
class UsersTable:
def insert_new_user(
@@ -243,6 +265,7 @@ class UsersTable:
email: str,
profile_image_url: str = "/user.png",
role: str = "pending",
username: Optional[str] = None,
oauth: Optional[dict] = None,
db: Optional[Session] = None,
) -> Optional[UserModel]:
@@ -257,6 +280,7 @@ class UsersTable:
"last_active_at": int(time.time()),
"created_at": int(time.time()),
"updated_at": int(time.time()),
"username": username,
"oauth": oauth,
}
)
@@ -299,8 +323,12 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
user = db.query(User).filter_by(email=email).first()
return UserModel.model_validate(user)
user = (
db.query(User)
.filter(func.lower(User.email) == email.lower())
.first()
)
return UserModel.model_validate(user) if user else None
except Exception:
return None
@@ -325,6 +353,29 @@ class UsersTable:
# You may want to log the exception here
return None
def get_user_by_scim_external_id(
self, provider: str, external_id: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db_context(db) as db: # type: Session
dialect_name = db.bind.dialect.name
query = db.query(User)
if dialect_name == "sqlite":
query = query.filter(
User.scim.contains({provider: {"external_id": external_id}})
)
elif dialect_name == "postgresql":
query = query.filter(
User.scim[provider].cast(JSONB)["external_id"].astext
== external_id
)
user = query.first()
return UserModel.model_validate(user) if user else None
except Exception:
return None
def get_users(
self,
filter: Optional[dict] = None,
@@ -334,7 +385,7 @@ class UsersTable:
) -> dict:
with get_db_context(db) as db:
# Join GroupMember so we can order by group_id when requested
query = db.query(User)
query = db.query(User).options(defer(User.profile_image_url))
if filter:
query_key = filter.get("query")
@@ -469,6 +520,7 @@ class UsersTable:
with get_db_context(db) as db:
users = (
db.query(User)
.options(defer(User.profile_image_url))
.join(GroupMember, User.id == GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.all()
@@ -479,7 +531,12 @@ class UsersTable:
self, user_ids: list[str], db: Optional[Session] = None
) -> list[UserStatusModel]:
with get_db_context(db) as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
users = (
db.query(User)
.options(defer(User.profile_image_url))
.filter(User.id.in_(user_ids))
.all()
)
return [UserModel.model_validate(user) for user in users]
def get_num_users(self, db: Optional[Session] = None) -> Optional[int]:
@@ -530,9 +587,12 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update({"role": role})
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.role = role
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@@ -542,12 +602,13 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{**form_data.model_dump(exclude_none=True)}
)
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
for key, value in form_data.model_dump(exclude_none=True).items():
setattr(user, key, value)
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@@ -557,12 +618,12 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{"profile_image_url": profile_image_url}
)
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.profile_image_url = profile_image_url
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@@ -573,12 +634,12 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{"last_active_at": int(time.time())}
)
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.last_active_at = int(time.time())
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@@ -615,17 +676,51 @@ class UsersTable:
except Exception:
return None
def update_user_scim_by_id(
self,
id: str,
provider: str,
external_id: str,
db: Optional[Session] = None,
) -> Optional[UserModel]:
"""
Update or insert a SCIM provider/external_id pair into the user's scim JSON field.
Example resulting structure:
{
"microsoft": { "external_id": "abc" },
"okta": { "external_id": "def" }
}
"""
try:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
scim = user.scim or {}
scim[provider] = {"external_id": external_id}
db.query(User).filter_by(id=id).update({"scim": scim})
db.commit()
return UserModel.model_validate(user)
except Exception:
return None
def update_user_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(updated)
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
for key, value in updated.items():
setattr(user, key, value)
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
# return UserModel(**user.dict())
except Exception as e:
print(e)
return None
@@ -740,6 +835,14 @@ class UsersTable:
)
return count
@staticmethod
def is_active(user: UserModel) -> bool:
"""Compute active status from an already-loaded UserModel (no DB hit)."""
if user.last_active_at:
three_minutes_ago = int(time.time()) - 180
return user.last_active_at >= three_minutes_ago
return False
def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=user_id).first()
+4 -2
View File
@@ -143,7 +143,7 @@ class DoclingLoader:
with open(self.file_path, "rb") as f:
headers = {}
if self.api_key:
headers["X-Api-Key"] = f"Bearer {self.api_key}"
headers["X-Api-Key"] = f"{self.api_key}"
r = requests.post(
f"{self.url}/v1/convert/file",
@@ -361,7 +361,9 @@ class Loader:
else:
if file_ext == "pdf":
loader = PyPDFLoader(
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
file_path,
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
mode=self.kwargs.get("PDF_LOADER_MODE", "page"),
)
elif file_ext == "csv":
loader = CSVLoader(file_path, autodetect_encoding=True)
@@ -8,7 +8,6 @@ from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, REQUESTS_VERIFY
from open_webui.retrieval.models.base_reranker import BaseReranker
from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
+29 -6
View File
@@ -29,9 +29,9 @@ from open_webui.models.knowledge import Knowledges
from open_webui.models.chats import Chats
from open_webui.models.notes import Notes
from open_webui.models.access_grants import AccessGrants
from open_webui.retrieval.vector.main import GetResult
from open_webui.utils.access_control import has_access
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.misc import get_message_list
@@ -601,7 +601,10 @@ async def agenerate_openai_batch_embeddings(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f"{url}/embeddings", headers=headers, json=form_data
f"{url}/embeddings",
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
@@ -691,7 +694,12 @@ async def agenerate_azure_openai_batch_embeddings(
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(full_url, headers=headers, json=form_data) as r:
async with session.post(
full_url,
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
if "data" in data:
@@ -999,7 +1007,12 @@ async def get_sources_from_items(
if note and (
user.role == "admin"
or note.user_id == user.id
or has_access(user.id, "read", note.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="read",
)
):
# User has access to the note
query_result = {
@@ -1091,7 +1104,12 @@ async def get_sources_from_items(
if knowledge_base and (
user.role == "admin"
or knowledge_base.user_id == user.id
or has_access(user.id, "read", knowledge_base.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="read",
)
):
if (
item.get("context") == "full"
@@ -1100,7 +1118,12 @@ async def get_sources_from_items(
if knowledge_base and (
user.role == "admin"
or knowledge_base.user_id == user.id
or has_access(user.id, "read", knowledge_base.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="read",
)
):
files = Knowledges.get_files_by_id(knowledge_base.id)
@@ -211,7 +211,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch
]
bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists(
@@ -234,7 +234,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch
]
bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def delete(
self,
@@ -263,7 +263,7 @@ class OpenSearchClient(VectorDBBase):
self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body
)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
@@ -256,8 +256,7 @@ class Oracle23aiClient(VectorDBBase):
with connection.cursor() as cursor:
try:
log.info("Creating Table document_chunk")
cursor.execute(
"""
cursor.execute("""
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE IF NOT EXISTS document_chunk (
@@ -274,12 +273,10 @@ class Oracle23aiClient(VectorDBBase):
RAISE;
END IF;
END;
"""
)
""")
log.info("Creating Index document_chunk_collection_name_idx")
cursor.execute(
"""
cursor.execute("""
BEGIN
EXECUTE IMMEDIATE '
CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx
@@ -291,12 +288,10 @@ class Oracle23aiClient(VectorDBBase):
RAISE;
END IF;
END;
"""
)
""")
log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx")
cursor.execute(
"""
cursor.execute("""
BEGIN
EXECUTE IMMEDIATE '
CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx
@@ -312,8 +307,7 @@ class Oracle23aiClient(VectorDBBase):
RAISE;
END IF;
END;
"""
)
""")
connection.commit()
log.info("Database initialization completed successfully.")
@@ -51,7 +51,6 @@ from open_webui.config import (
PGVECTOR_USE_HALFVEC,
)
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
USE_HALFVEC = PGVECTOR_USE_HALFVEC
@@ -121,34 +120,26 @@ class PgvectorClient(VectorDBBase):
# Ensure the pgvector extension is available
# Use a conditional check to avoid permission issues on Azure PostgreSQL
if PGVECTOR_CREATE_EXTENSION:
self.session.execute(
text(
"""
self.session.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
CREATE EXTENSION IF NOT EXISTS vector;
END IF;
END $$;
"""
)
)
"""))
if PGVECTOR_PGCRYPTO:
# Ensure the pgcrypto extension is available for encryption
# Use a conditional check to avoid permission issues on Azure PostgreSQL
self.session.execute(
text(
"""
self.session.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN
CREATE EXTENSION IF NOT EXISTS pgcrypto;
END IF;
END $$;
"""
)
)
"""))
if not PGVECTOR_PGCRYPTO_KEY:
raise ValueError(
@@ -216,15 +207,13 @@ class PgvectorClient(VectorDBBase):
def _ensure_vector_index(self, index_method: str, index_options: str) -> None:
index_name = "idx_document_chunk_vector"
existing_index_def = self.session.execute(
text(
"""
text("""
SELECT indexdef
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'document_chunk'
AND indexname = :index_name
"""
),
"""),
{"index_name": index_name},
).scalar()
@@ -310,8 +299,7 @@ class PgvectorClient(VectorDBBase):
# Ensure metadata is converted to its JSON text representation
json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
text("""
INSERT INTO document_chunk
(id, vector, collection_name, text, vmetadata)
VALUES (
@@ -320,8 +308,7 @@ class PgvectorClient(VectorDBBase):
pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO NOTHING
"""
),
"""),
{
"id": item["id"],
"vector": vector,
@@ -363,8 +350,7 @@ class PgvectorClient(VectorDBBase):
vector = self.adjust_vector_length(item["vector"])
json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
text("""
INSERT INTO document_chunk
(id, vector, collection_name, text, vmetadata)
VALUES (
@@ -377,8 +363,7 @@ class PgvectorClient(VectorDBBase):
collection_name = EXCLUDED.collection_name,
text = EXCLUDED.text,
vmetadata = EXCLUDED.vmetadata
"""
),
"""),
{
"id": item["id"],
"vector": vector,
@@ -33,7 +33,6 @@ from open_webui.config import (
)
from open_webui.retrieval.vector.utils import process_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
@@ -12,9 +12,13 @@ from open_webui.retrieval.vector.main import (
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.config import (
WEAVIATE_HTTP_HOST,
WEAVIATE_GRPC_HOST,
WEAVIATE_HTTP_PORT,
WEAVIATE_GRPC_PORT,
WEAVIATE_API_KEY,
WEAVIATE_HTTP_SECURE,
WEAVIATE_GRPC_SECURE,
WEAVIATE_SKIP_INIT_CHECKS,
)
@@ -52,9 +56,13 @@ class WeaviateClient(VectorDBBase):
try:
# Build connection parameters
connection_params = {
"host": WEAVIATE_HTTP_HOST,
"port": WEAVIATE_HTTP_PORT,
"http_host": WEAVIATE_HTTP_HOST,
"http_port": WEAVIATE_HTTP_PORT,
"http_secure": WEAVIATE_HTTP_SECURE,
"grpc_host": WEAVIATE_GRPC_HOST,
"grpc_port": WEAVIATE_GRPC_PORT,
"grpc_secure": WEAVIATE_GRPC_SECURE,
"skip_init_checks": WEAVIATE_SKIP_INIT_CHECKS,
}
# Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty
@@ -63,7 +71,7 @@ class WeaviateClient(VectorDBBase):
weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY)
)
self.client = weaviate.connect_to_local(**connection_params)
self.client = weaviate.connect_to_custom(**connection_params)
self.client.connect()
except Exception as e:
raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e
+10 -10
View File
@@ -4,6 +4,7 @@ KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figu
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
# Removes large/redundant fields from metadata dict.
metadata = {
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
}
@@ -13,16 +14,15 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
def process_metadata(
metadata: dict[str, any],
) -> dict[str, any]:
# Removes large fields and converts non-serializable types (datetime, list, dict) to strings.
result = {}
for key, value in metadata.items():
# Remove large fields
# Skip large fields
if key in KEYS_TO_EXCLUDE:
del metadata[key]
continue
# Convert non-serializable fields to strings
if (
isinstance(value, datetime)
or isinstance(value, list)
or isinstance(value, dict)
):
metadata[key] = str(value)
return metadata
if isinstance(value, (datetime, list, dict)):
result[key] = str(value)
else:
result[key] = value
return result
+2 -2
View File
@@ -8,7 +8,7 @@ from fastapi import Request
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
log = logging.getLogger(__name__)
@@ -31,7 +31,7 @@ def search_external(
chat_id = getattr(request.state, "chat_id", None)
if chat_id:
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
response = requests.post(
external_url,
@@ -3,7 +3,6 @@ from typing import Optional, List
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
log = logging.getLogger(__name__)
@@ -5,7 +5,6 @@ import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
+1 -1
View File
@@ -174,7 +174,7 @@ class URLProcessingMixin:
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
if self.verify_ssl and not verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
+164
View File
@@ -0,0 +1,164 @@
import base64
import io
import json
import logging
import os
from typing import Optional, List
import requests
from fastapi import Request
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
from xml.etree import ElementTree as ET
from xml.etree.ElementTree import Element
log = logging.getLogger(__name__)
def xml_element_contents_to_string(element: Element) -> str:
buffer = [element.text if element.text else ""]
for child in element:
buffer.append(xml_element_contents_to_string(child))
buffer.append(element.tail if element.tail else "")
return "".join(buffer)
def search_yandex(
request: Request,
yandex_search_url: str,
yandex_search_api_key: str,
yandex_search_config: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
user=None,
) -> List[SearchResult]:
try:
headers = {
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
"Authorization": f"Api-Key {yandex_search_api_key}",
}
if user is not None:
headers = include_user_info_headers(headers, user)
chat_id = getattr(request.state, "chat_id", None)
if chat_id:
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
payload = {} if yandex_search_config == "" else json.loads(yandex_search_config)
if type(payload.get("query", None)) != dict:
payload["query"] = {}
if "searchType" not in payload["query"]:
payload["query"]["searchType"] = "SEARCH_TYPE_RU"
payload["query"]["queryText"] = query
if type(payload.get("groupSpec", None)) != dict:
payload["groupSpec"] = {}
if "groupMode" not in payload["groupSpec"]:
payload["groupSpec"]["groupMode"] = "GROUP_MODE_DEEP"
payload["groupSpec"]["groupsOnPage"] = count
payload["groupSpec"]["docsInGroup"] = 1
response = requests.post(
(
"https://searchapi.api.cloud.yandex.net/v2/web/search"
if yandex_search_url == ""
else yandex_search_url
),
headers=headers,
json=payload,
)
response.raise_for_status()
response_body = response.json()
if "rawData" not in response_body:
raise Exception(f"No `rawData` in response body: {response_body}")
search_result_body_bytes = base64.decodebytes(
bytes(response_body["rawData"], "utf-8")
)
doc_root = ET.parse(io.BytesIO(search_result_body_bytes))
results = []
for group in doc_root.findall("response/results/grouping/group"):
results.append(
{
"url": xml_element_contents_to_string(group.find("doc/url")).strip(
"\n"
),
"title": xml_element_contents_to_string(
group.find("doc/title")
).strip("\n"),
"snippet": xml_element_contents_to_string(
group.find("doc/passages/passage")
),
}
)
results = get_filtered_results(results, filter_list)
results = [
SearchResult(
link=result.get("url"),
title=result.get("title"),
snippet=result.get("snippet"),
)
for result in results[:count]
]
log.info(f"Yandex search results: {results}")
return results
except Exception as e:
log.error(f"Error in search: {e}")
return []
if __name__ == "__main__":
from starlette.datastructures import Headers
from fastapi import FastAPI
result = search_yandex(
Request(
{
"type": "http",
"asgi.version": "3.0",
"asgi.spec_version": "2.0",
"method": "GET",
"path": "/internal",
"query_string": b"",
"headers": Headers({}).raw,
"client": ("127.0.0.1", 12345),
"server": ("127.0.0.1", 80),
"scheme": "http",
"app": FastAPI(),
},
None,
),
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
os.environ.get(
"YANDEX_WEB_SEARCH_CONFIG", '{"query": {"searchType": "SEARCH_TYPE_COM"}}'
),
"TOP movies of the past year",
3,
)
print(result)
+454
View File
@@ -0,0 +1,454 @@
from typing import Optional
from datetime import datetime, timedelta
from collections import defaultdict
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from open_webui.models.chat_messages import ChatMessages, ChatMessageModel
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.models.feedbacks import Feedbacks
from open_webui.utils.auth import get_admin_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
router = APIRouter()
####################
# Response Models
####################
class ModelAnalyticsEntry(BaseModel):
model_id: str
count: int
class ModelAnalyticsResponse(BaseModel):
models: list[ModelAnalyticsEntry]
class UserAnalyticsEntry(BaseModel):
user_id: str
name: Optional[str] = None
email: Optional[str] = None
count: int
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
class UserAnalyticsResponse(BaseModel):
users: list[UserAnalyticsEntry]
####################
# Endpoints
####################
@router.get("/models", response_model=ModelAnalyticsResponse)
async def get_model_analytics(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts per model."""
counts = ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
models = [
ModelAnalyticsEntry(model_id=model_id, count=count)
for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
]
return ModelAnalyticsResponse(models=models)
@router.get("/users", response_model=UserAnalyticsResponse)
async def get_user_analytics(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
limit: int = Query(50, description="Max users to return"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts and token usage per user with user info."""
counts = ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
token_usage = ChatMessages.get_token_usage_by_user(
start_date=start_date, end_date=end_date, db=db
)
# Get user info for top users
top_user_ids = [
uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]
]
user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)}
users = []
for user_id in top_user_ids:
u = user_info.get(user_id)
tokens = token_usage.get(user_id, {})
users.append(
UserAnalyticsEntry(
user_id=user_id,
name=u.name if u else None,
email=u.email if u else None,
count=counts[user_id],
input_tokens=tokens.get("input_tokens", 0),
output_tokens=tokens.get("output_tokens", 0),
total_tokens=tokens.get("total_tokens", 0),
)
)
return UserAnalyticsResponse(users=users)
@router.get("/messages", response_model=list[ChatMessageModel])
async def get_messages(
model_id: Optional[str] = Query(None, description="Filter by model ID"),
user_id: Optional[str] = Query(None, description="Filter by user ID"),
chat_id: Optional[str] = Query(None, description="Filter by chat ID"),
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
skip: int = Query(0),
limit: int = Query(50, le=100),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Query messages with filters."""
if chat_id:
return ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db)
elif model_id:
return ChatMessages.get_messages_by_model_id(
model_id=model_id,
start_date=start_date,
end_date=end_date,
skip=skip,
limit=limit,
db=db,
)
elif user_id:
return ChatMessages.get_messages_by_user_id(
user_id=user_id, skip=skip, limit=limit, db=db
)
else:
# Return empty if no filter specified
return []
class SummaryResponse(BaseModel):
total_messages: int
total_chats: int
total_models: int
total_users: int
@router.get("/summary", response_model=SummaryResponse)
async def get_summary(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get summary statistics for the dashboard."""
model_counts = ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
user_counts = ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
chat_counts = ChatMessages.get_message_count_by_chat(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
return SummaryResponse(
total_messages=sum(model_counts.values()),
total_chats=len(chat_counts),
total_models=len(model_counts),
total_users=len(user_counts),
)
class DailyStatsEntry(BaseModel):
date: str
models: dict[str, int]
class DailyStatsResponse(BaseModel):
data: list[DailyStatsEntry]
@router.get("/daily", response_model=DailyStatsResponse)
async def get_daily_stats(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
granularity: str = Query("daily", description="Granularity: 'hourly' or 'daily'"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts grouped by model for time-series chart."""
if granularity == "hourly":
counts = ChatMessages.get_hourly_message_counts_by_model(
start_date=start_date, end_date=end_date, db=db
)
else:
counts = ChatMessages.get_daily_message_counts_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
return DailyStatsResponse(
data=[
DailyStatsEntry(date=date, models=models)
for date, models in sorted(counts.items())
]
)
class TokenUsageEntry(BaseModel):
model_id: str
input_tokens: int
output_tokens: int
total_tokens: int
message_count: int
class TokenUsageResponse(BaseModel):
models: list[TokenUsageEntry]
total_input_tokens: int
total_output_tokens: int
total_tokens: int
@router.get("/tokens", response_model=TokenUsageResponse)
async def get_token_usage(
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get token usage aggregated by model."""
usage = ChatMessages.get_token_usage_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
models = [
TokenUsageEntry(model_id=model_id, **data)
for model_id, data in sorted(usage.items(), key=lambda x: -x[1]["total_tokens"])
]
total_input = sum(m.input_tokens for m in models)
total_output = sum(m.output_tokens for m in models)
return TokenUsageResponse(
models=models,
total_input_tokens=total_input,
total_output_tokens=total_output,
total_tokens=total_input + total_output,
)
####################
# Model Chats Browser
####################
class ModelChatEntry(BaseModel):
chat_id: str
user_id: Optional[str] = None
user_name: Optional[str] = None
first_message: Optional[str] = None
updated_at: int
class ModelChatsResponse(BaseModel):
chats: list[ModelChatEntry]
total: int
@router.get("/models/{model_id}/chats", response_model=ModelChatsResponse)
async def get_model_chats(
model_id: str,
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
skip: int = Query(0),
limit: int = Query(50, le=100),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get chats that used a specific model, with preview and feedback info."""
# Get chat IDs that used this model
chat_ids = ChatMessages.get_chat_ids_by_model_id(
model_id=model_id,
start_date=start_date,
end_date=end_date,
skip=skip,
limit=limit,
db=db,
)
if not chat_ids:
return ModelChatsResponse(chats=[], total=0)
# Get chat details from messages only
chats_data = []
for chat_id in chat_ids:
messages = ChatMessages.get_messages_by_chat_id(chat_id, db=db)
if not messages:
continue
# Get user_id from first user message
first_user_msg = next((m for m in messages if m.role == "user"), None)
user_id = first_user_msg.user_id if first_user_msg else None
# Extract first message content as preview
first_message = None
if first_user_msg and first_user_msg.content:
content = first_user_msg.content
if isinstance(content, str):
first_message = content[:200]
elif isinstance(content, list):
text_parts = [b.get("text", "") for b in content if isinstance(b, dict)]
first_message = " ".join(text_parts)[:200]
# Get user info
user_name = None
if user_id:
user_info = Users.get_user_by_id(user_id, db=db)
user_name = user_info.name if user_info else None
# Timestamps from messages
updated_at = max(m.created_at for m in messages) if messages else 0
chats_data.append(
ModelChatEntry(
chat_id=chat_id,
user_id=user_id,
user_name=user_name,
first_message=first_message,
updated_at=updated_at,
)
)
return ModelChatsResponse(chats=chats_data, total=len(chats_data))
####################
# Model Overview
####################
class HistoryEntry(BaseModel):
date: str
won: int = 0
lost: int = 0
class TagEntry(BaseModel):
tag: str
count: int
class ModelOverviewResponse(BaseModel):
history: list[HistoryEntry]
tags: list[TagEntry]
@router.get("/models/{model_id}/overview", response_model=ModelOverviewResponse)
async def get_model_overview(
model_id: str,
days: int = Query(30, description="Number of days of history (0 for all)"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get model overview with feedback history and chat tags."""
# Get chat IDs that used this model
chat_ids = ChatMessages.get_chat_ids_by_model_id(
model_id=model_id,
start_date=None,
end_date=None,
skip=0,
limit=10000, # Get all chats
db=db,
)
# Get feedback history per day
history_counts: dict[str, dict] = defaultdict(lambda: {"won": 0, "lost": 0})
# Calculate start date for history
now = datetime.now()
start_dt = None
if days > 0:
start_dt = now - timedelta(days=days)
for chat_id in chat_ids:
feedbacks = Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db)
for fb in feedbacks:
if fb.data and "rating" in fb.data:
rating = fb.data["rating"]
fb_date = datetime.fromtimestamp(fb.created_at)
# Filter by date range
if start_dt and fb_date < start_dt:
continue
date_str = fb_date.strftime("%Y-%m-%d")
if rating == 1:
history_counts[date_str]["won"] += 1
elif rating == -1:
history_counts[date_str]["lost"] += 1
# Fill in missing days
history = []
if history_counts or days > 0:
end_dt = now
if days > 0:
current = start_dt
elif history_counts:
# Find earliest date
min_date = min(history_counts.keys())
current = datetime.strptime(min_date, "%Y-%m-%d")
else:
current = now
while current <= end_dt:
date_str = current.strftime("%Y-%m-%d")
counts = history_counts.get(date_str, {"won": 0, "lost": 0})
history.append(
HistoryEntry(
date=date_str,
won=counts["won"],
lost=counts["lost"],
)
)
current += timedelta(days=1)
# Get chat tags
tag_counts: dict[str, int] = defaultdict(int)
for chat_id in chat_ids:
chat = Chats.get_chat_by_id(chat_id, db=db)
if chat and chat.meta:
for tag in chat.meta.get("tags", []):
tag_counts[tag] += 1
# Sort by count and take top 10
tags = [
TagEntry(tag=tag, count=count)
for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]
]
return ModelOverviewResponse(history=history, tags=tags)
+23 -18
View File
@@ -53,11 +53,11 @@ from open_webui.env import (
ENV,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
DEVICE_TYPE,
ENABLE_FORWARD_USER_INFO_HEADERS,
)
router = APIRouter()
# Constants
@@ -334,8 +334,8 @@ def load_speech_pipeline(request):
async def speech(request: Request, user=Depends(get_verified_user)):
if request.app.state.config.TTS_ENGINE == "":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != "admin" and not has_permission(
@@ -639,12 +639,14 @@ def transcription_handler(request, file_path, metadata, user=None):
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
headers = include_user_info_headers(headers, user)
r = requests.post(
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
headers=headers,
files={"file": (filename, open(file_path, "rb"))},
data=payload,
)
with open(file_path, "rb") as audio_file:
r = requests.post(
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
headers=headers,
files={"file": (filename, audio_file)},
data=payload,
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
if r.status_code == 200:
# Successful transcription
@@ -704,6 +706,7 @@ def transcription_handler(request, file_path, metadata, user=None):
headers=headers,
params=params,
data=file_data,
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
if r.status_code == 200:
@@ -815,6 +818,7 @@ def transcription_handler(request, file_path, metadata, user=None):
headers={
"Ocp-Apim-Subscription-Key": api_key,
},
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
r.raise_for_status()
@@ -954,6 +958,7 @@ def transcription_handler(request, file_path, metadata, user=None):
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
r.raise_for_status()
@@ -997,6 +1002,7 @@ def transcription_handler(request, file_path, metadata, user=None):
headers={
"Authorization": f"Bearer {api_key}",
},
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
r.raise_for_status()
@@ -1169,12 +1175,6 @@ def transcription(
language: Optional[str] = Form(None),
user=Depends(get_verified_user),
):
if request.app.state.config.STT_ENGINE == "":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != "admin" and not has_permission(
user.id, "chat.stt", request.app.state.config.USER_PERMISSIONS
):
@@ -1246,7 +1246,8 @@ def get_available_models(request: Request) -> list[dict]:
):
try:
response = requests.get(
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models"
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models",
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
data = response.json()
@@ -1292,7 +1293,8 @@ def get_available_voices(request) -> dict:
):
try:
response = requests.get(
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices"
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices",
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
data = response.json()
@@ -1336,7 +1338,9 @@ def get_available_voices(request) -> dict:
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY
}
response = requests.get(url, headers=headers)
response = requests.get(
url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST
)
response.raise_for_status()
voices = response.json()
@@ -1368,6 +1372,7 @@ def get_elevenlabs_voices(api_key: str) -> dict:
"xi-api-key": api_key,
"Content-Type": "application/json",
},
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
voices_data = response.json()
+250 -167
View File
@@ -1,3 +1,4 @@
import asyncio
import re
import uuid
import time
@@ -19,6 +20,7 @@ from open_webui.models.auths import (
UpdatePasswordForm,
)
from open_webui.models.users import (
UserModel,
UserProfileImageResponse,
Users,
UpdateProfileForm,
@@ -37,6 +39,8 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
ENABLE_INITIAL_ADMIN_SIGNUP,
ENABLE_OAUTH_TOKEN_EXCHANGE,
AIOHTTP_CLIENT_SESSION_SSL,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response, JSONResponse
@@ -45,6 +49,8 @@ from open_webui.config import (
ENABLE_OAUTH_SIGNUP,
ENABLE_LDAP,
ENABLE_PASSWORD_AUTH,
OAUTH_PROVIDERS,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
)
from pydantic import BaseModel
@@ -87,6 +93,63 @@ signin_rate_limiter = RateLimiter(
redis_client=get_redis_client(), limit=5 * 3, window=60 * 3
)
def create_session_response(
request: Request, user, db, response: Response = None, set_cookie: bool = False
) -> dict:
"""
Create JWT token and build session response for a user.
Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints.
Args:
request: FastAPI request object
user: User object
db: Database session
response: FastAPI response object (required if set_cookie is True)
set_cookie: Whether to set the auth cookie on the response
"""
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
if set_cookie and response:
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": f"/api/v1/users/{user.id}/profile/image",
"permissions": user_permissions,
}
############################
# GetSessionUser
############################
@@ -315,7 +378,7 @@ async def ldap_auth(
auto_bind="NONE",
authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS",
)
if not connection_app.bind():
if not await asyncio.to_thread(connection_app.bind):
raise HTTPException(400, detail="Application account bind failed")
ENABLE_LDAP_GROUP_MANAGEMENT = (
@@ -336,7 +399,8 @@ async def ldap_auth(
)
log.info(f"LDAP search attributes: {search_attributes}")
search_success = connection_app.search(
search_success = await asyncio.to_thread(
connection_app.search,
search_base=LDAP_SEARCH_BASE,
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
attributes=search_attributes,
@@ -440,7 +504,7 @@ async def ldap_auth(
auto_bind="NONE",
authentication="SIMPLE",
)
if not connection_user.bind():
if not await asyncio.to_thread(connection_user.bind):
raise HTTPException(400, "Authentication failed.")
user = Users.get_user_by_email(email, db=db)
@@ -482,36 +546,6 @@ async def ldap_auth(
user = Auths.authenticate_user_by_email(email, db=db)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=(
datetime.datetime.fromtimestamp(
expires_at, datetime.timezone.utc
)
if expires_at
else None
),
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
if (
user.role != "admin"
and ENABLE_LDAP_GROUP_MANAGEMENT
@@ -527,17 +561,9 @@ async def ldap_auth(
except Exception as e:
log.error(f"Failed to sync groups for user {user.id}: {e}")
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
return create_session_response(
request, user, db, response, set_cookie=True
)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
@@ -580,10 +606,11 @@ async def signin(
pass
if not Users.get_user_by_email(email.lower(), db=db):
await signup(
await signup_handler(
request,
response,
SignupForm(email=email, password=str(uuid.uuid4()), name=name),
email,
str(uuid.uuid4()),
name,
db=db,
)
@@ -611,10 +638,11 @@ async def signin(
if Users.has_users(db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
await signup(
await signup_handler(
request,
response,
SignupForm(email=admin_email, password=admin_password, name="User"),
admin_email,
admin_password,
"User",
db=db,
)
@@ -646,48 +674,7 @@ async def signin(
)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
return create_session_response(request, user, db, response, set_cookie=True)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@@ -697,6 +684,62 @@ async def signin(
############################
async def signup_handler(
request: Request,
email: str,
password: str,
name: str,
profile_image_url: str = "/user.png",
*,
db: Session,
) -> UserModel:
"""
Core user-creation logic shared by the signup endpoint and
trusted-header / no-auth auto-registration flows.
Returns the newly created UserModel.
Raises HTTPException on failure.
"""
has_users = Users.has_users(db=db)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
hashed = get_password_hash(password)
user = Auths.insert_new_auth(
email=email.lower(),
password=hashed,
name=name,
profile_image_url=profile_image_url,
role=role,
db=db,
)
if not user:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
return user
@router.post("/signup", response_model=SessionUserResponse)
async def signup(
request: Request,
@@ -735,84 +778,17 @@ async def signup(
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
user = Auths.insert_new_auth(
form_data.email.lower(),
hashed,
user = await signup_handler(
request,
form_data.email,
form_data.password,
form_data.name,
form_data.profile_image_url,
role,
db=db,
)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
return create_session_response(request, user, db, response, set_cookie=True)
except HTTPException:
raise
except Exception as err:
log.error(f"Signup error: {str(err)}")
raise HTTPException(500, detail="An internal error occurred during signup.")
@@ -950,10 +926,12 @@ async def add_user(
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"profile_image_url": f"/api/v1/users/{user.id}/profile/image",
}
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
except HTTPException:
raise
except Exception as err:
log.error(f"Add user error: {str(err)}")
raise HTTPException(
@@ -1283,3 +1261,108 @@ async def get_api_key(
}
else:
raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
############################
# Token Exchange
############################
class TokenExchangeForm(BaseModel):
token: str # OAuth access token from external provider
@router.post("/oauth/{provider}/token/exchange", response_model=SessionUserResponse)
async def token_exchange(
request: Request,
response: Response,
provider: str,
form_data: TokenExchangeForm,
db: Session = Depends(get_session),
):
"""
Exchange an external OAuth provider token for an OpenWebUI JWT.
This endpoint is disabled by default. Set ENABLE_OAUTH_TOKEN_EXCHANGE=True to enable.
"""
if not ENABLE_OAUTH_TOKEN_EXCHANGE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token exchange is disabled",
)
provider = provider.lower()
# Check if provider is configured
if provider not in OAUTH_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Provider '{provider}' is not configured",
)
# Get the OAuth client for this provider
oauth_manager = request.app.state.oauth_manager
client = oauth_manager.get_client(provider)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"OAuth client for '{provider}' not found",
)
# Validate the token by calling the userinfo endpoint
try:
token_data = {"access_token": form_data.token, "token_type": "Bearer"}
user_data = await client.userinfo(token=token_data)
if not user_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token or unable to fetch user info",
)
except Exception as e:
log.warning(f"Token exchange failed for provider {provider}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token or unable to validate with provider",
)
# Extract user information from the token claims
email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM
username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM
# Get sub claim
sub = user_data.get(
request.app.state.config.OAUTH_SUB_CLAIM
or OAUTH_PROVIDERS[provider].get("sub_claim", "sub")
)
if not sub:
log.warning(f"Token exchange failed: sub claim missing from user data")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token missing required 'sub' claim",
)
email = user_data.get(email_claim, "")
if not email:
log.warning(f"Token exchange failed: email claim missing from user data")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token missing required email claim",
)
email = email.lower()
# Try to find the user by OAuth sub
user = Users.get_user_by_oauth_sub(provider, sub, db=db)
if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
# Try to find by email if merge is enabled
user = Users.get_user_by_email(email, db=db)
if user:
# Link the OAuth sub to this user
Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
if not user:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not found. Please sign in via the web interface first.",
)
return create_session_response(request, user, db)
+128 -121
View File
@@ -22,6 +22,7 @@ from open_webui.models.users import (
UserListResponse,
UserModelResponse,
Users,
UserModel,
UserNameResponse,
)
@@ -35,6 +36,7 @@ from open_webui.models.channels import (
ChannelWebhookModel,
ChannelWebhookForm,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.messages import (
Messages,
MessageModel,
@@ -59,12 +61,7 @@ from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import (
has_access,
get_users_with_access,
get_permitted_group_and_user_ids,
has_permission,
)
from open_webui.utils.access_control import has_permission
from open_webui.utils.webhook import post_webhook
from open_webui.utils.channels import extract_mentions, replace_mentions
from open_webui.internal.db import get_session
@@ -75,12 +72,72 @@ log = logging.getLogger(__name__)
router = APIRouter()
def channel_has_access(
user_id: str,
channel: ChannelModel,
permission: str = "read",
strict: bool = True,
db: Optional[Session] = None,
) -> bool:
if AccessGrants.has_access(
user_id=user_id,
resource_type="channel",
resource_id=channel.id,
permission=permission,
db=db,
):
return True
if (
not strict
and permission == "write"
and has_public_read_access_grant(channel.access_grants)
):
return True
return False
def get_channel_users_with_access(
channel: ChannelModel, permission: str = "read", db: Optional[Session] = None
):
return AccessGrants.get_users_with_access(
resource_type="channel",
resource_id=channel.id,
permission=permission,
db=db,
)
def get_channel_permitted_group_and_user_ids(
channel: ChannelModel, permission: str = "read"
) -> Optional[dict[str, list[str]]]:
if permission == "read" and has_public_read_access_grant(channel.access_grants):
return None
user_ids = []
group_ids = []
for grant in channel.access_grants:
if grant.permission != permission:
continue
if grant.principal_type == "group":
group_ids.append(grant.principal_id)
elif grant.principal_type == "user" and grant.principal_id != "*":
user_ids.append(grant.principal_id)
return {
"user_ids": list(dict.fromkeys(user_ids)),
"group_ids": list(dict.fromkeys(group_ids)),
}
############################
# Channels Enabled Dependency
############################
def check_channels_access(request: Request):
def check_channels_access(request: Request, user: Optional[UserModel] = None):
"""Dependency to ensure channels are globally enabled."""
if not request.app.state.config.ENABLE_CHANNELS:
raise HTTPException(
@@ -88,6 +145,15 @@ def check_channels_access(request: Request):
detail="Channels are not enabled",
)
if user:
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
############################
# GetChatList
@@ -108,14 +174,7 @@ async def get_channels(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
check_channels_access(request, user)
channels = Channels.get_channels_by_user_id(user.id, db=db)
channel_list = []
@@ -145,7 +204,7 @@ async def get_channels(
UserIdNameStatusResponse(
**{
**user.model_dump(),
"is_active": Users.is_user_active(user.id, db=db),
"is_active": Users.is_active(user),
}
)
for user in Users.get_users_by_user_ids(user_ids, db=db)
@@ -188,15 +247,7 @@ async def get_dm_channel_by_user_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
check_channels_access(request, user)
try:
existing_channel = Channels.get_dm_channel_by_user_ids(
[user.id, user_id], db=db
@@ -268,14 +319,7 @@ async def create_new_channel(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
check_channels_access(request, user)
if form_data.type not in ["group", "dm"] and user.role != "admin":
# Only admins can create standard channels (joined by default)
@@ -355,7 +399,7 @@ async def get_channel_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(
@@ -380,7 +424,7 @@ async def get_channel_by_id(
UserIdNameStatusResponse(
**{
**user.model_dump(),
"is_active": Users.is_user_active(user.id, db=db),
"is_active": Users.is_active(user),
}
)
for user in Users.get_users_by_user_ids(user_ids, db=db)
@@ -408,22 +452,22 @@ async def get_channel_by_id(
}
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
write_access = has_access(
write_access = channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
)
user_count = len(get_users_with_access("read", channel.access_control))
user_count = len(get_channel_users_with_access(channel, "read", db=db))
channel_member = Channels.get_member_by_channel_and_user_id(
channel.id, user.id, db=db
@@ -467,7 +511,7 @@ async def get_channel_members_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
@@ -496,9 +540,7 @@ async def get_channel_members_by_id(
return {
"users": [
UserModelResponse(
**user.model_dump(), is_active=Users.is_user_active(user.id, db=db)
)
UserModelResponse(**user.model_dump(), is_active=Users.is_active(user))
for user in users
],
"total": total,
@@ -517,8 +559,8 @@ async def get_channel_members_by_id(
filter["channel_id"] = channel.id
else:
filter["roles"] = ["!pending"]
permitted_ids = get_permitted_group_and_user_ids(
"read", channel.access_control
permitted_ids = get_channel_permitted_group_and_user_ids(
channel, permission="read"
)
if permitted_ids:
filter["user_ids"] = permitted_ids.get("user_ids")
@@ -531,9 +573,7 @@ async def get_channel_members_by_id(
return {
"users": [
UserModelResponse(
**user.model_dump(), is_active=Users.is_user_active(user.id, db=db)
)
UserModelResponse(**user.model_dump(), is_active=Users.is_active(user))
for user in users
],
"total": total,
@@ -593,15 +633,7 @@ async def add_members_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(
@@ -643,14 +675,7 @@ async def remove_members_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
@@ -689,14 +714,7 @@ async def update_channel_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
@@ -731,14 +749,7 @@ async def delete_channel_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
@@ -788,7 +799,7 @@ async def get_channel_messages(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(
@@ -801,8 +812,8 @@ async def get_channel_messages(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -878,8 +889,8 @@ async def get_pinned_channel_messages(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -936,7 +947,7 @@ async def get_pinned_channel_messages(
async def send_notification(
name, webui_url, channel, message, active_user_ids, db=None
):
users = get_users_with_access("read", channel.access_control)
users = get_channel_users_with_access(channel, "read", db=db)
for user in users:
if (user.id not in active_user_ids) and Channels.is_user_channel_member(
@@ -1055,7 +1066,7 @@ async def model_response_handler(request, channel, message, user, db=None):
f"{username}: {replace_mentions(thread_message.content)}"
)
thread_message_files = thread_message.data.get("files", [])
thread_message_files = (thread_message.data or {}).get("files", [])
for file in thread_message_files:
if file.get("type", "") == "image":
images.append(file.get("url", ""))
@@ -1163,10 +1174,10 @@ async def new_message_handler(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
if user.role != "admin" and not channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
):
@@ -1256,15 +1267,17 @@ async def post_new_message(
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
# NOTE: We intentionally do NOT pass db to background_handler.
# Background tasks should manage their own short-lived sessions to avoid
# holding database connections during slow operations (e.g., LLM calls).
async def background_handler():
await model_response_handler(request, channel, message, user, db)
await model_response_handler(request, channel, message, user)
await send_notification(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBUI_URL,
channel,
message,
active_user_ids,
db=db,
)
background_tasks.add_task(background_handler)
@@ -1306,8 +1319,8 @@ async def get_channel_message(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1360,8 +1373,8 @@ async def get_channel_message_data(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1414,8 +1427,8 @@ async def pin_channel_message(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1480,8 +1493,8 @@ async def get_channel_thread_messages(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1565,9 +1578,7 @@ async def update_message_by_id(
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
)
and not channel_has_access(user.id, channel, permission="read", db=db)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -1632,10 +1643,10 @@ async def add_reaction_to_message(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
if user.role != "admin" and not channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
):
@@ -1711,10 +1722,10 @@ async def remove_reaction_by_id_and_user_id_and_name(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
if user.role != "admin" and not channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
):
@@ -1806,10 +1817,10 @@ async def delete_message_by_id(
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(
and not channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
)
@@ -1872,13 +1883,9 @@ async def delete_message_by_id(
@router.get("/webhooks/{webhook_id}/profile/image")
async def get_webhook_profile_image(
webhook_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_user)):
"""Get webhook profile image by webhook ID."""
webhook = Channels.get_webhook_by_id(webhook_id, db=db)
webhook = Channels.get_webhook_by_id(webhook_id)
if not webhook:
# Return default favicon if webhook not found
return FileResponse(f"{STATIC_DIR}/favicon.png")
+58 -29
View File
@@ -16,6 +16,7 @@ from open_webui.models.chats import (
ChatResponse,
Chats,
ChatTitleIdResponse,
SharedChatResponse,
ChatStatsExport,
AggregateChatStats,
ChatBody,
@@ -357,9 +358,7 @@ def _process_chat_for_export(chat) -> Optional[ChatStatsExport]:
return None
def calculate_chat_stats(
user_id, skip=0, limit=10, filter=None, db: Optional[Session] = None
):
def calculate_chat_stats(user_id, skip=0, limit=10, filter=None):
if filter is None:
filter = {}
@@ -368,7 +367,6 @@ def calculate_chat_stats(
skip=skip,
limit=limit,
filter=filter,
db=db,
)
chat_stats_export_list = []
@@ -424,7 +422,6 @@ async def export_chat_stats(
page: Optional[int] = 1,
stream: bool = False,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
# Check if the user has permission to share/export chats
if (user.role != "admin") and (
@@ -455,7 +452,7 @@ async def export_chat_stats(
skip = (page - 1) * limit
chat_stats_export_list, total = await asyncio.to_thread(
calculate_chat_stats, user.id, skip, limit, filter, db=db
calculate_chat_stats, user.id, skip, limit, filter
)
return ChatStatsExportList(
@@ -862,6 +859,48 @@ async def unarchive_all_chats(
return Chats.unarchive_all_chats_by_user_id(user.id, db=db)
############################
# GetSharedChats
############################
@router.get("/shared", response_model=list[SharedChatResponse])
async def get_shared_session_user_chat_list(
page: Optional[int] = None,
query: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if page is None:
page = 1
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
chat_list = [
SharedChatResponse(**chat.model_dump())
for chat in Chats.get_shared_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
]
return chat_list
############################
# GetSharedChatById
############################
@@ -1092,9 +1131,9 @@ async def delete_chat_by_id(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
for tag in chat.meta.get("tags", []):
if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 1:
Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db)
Chats.delete_orphan_tags_for_user(
chat.meta.get("tags", []), user.id, threshold=1, db=db
)
result = Chats.delete_chat_by_id(id, db=db)
@@ -1114,9 +1153,9 @@ async def delete_chat_by_id(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
for tag in chat.meta.get("tags", []):
if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 1:
Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db)
Chats.delete_orphan_tags_for_user(
chat.meta.get("tags", []), user.id, threshold=1, db=db
)
result = Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
return result
@@ -1278,21 +1317,13 @@ async def archive_chat_by_id(
if chat:
chat = Chats.toggle_chat_archive_by_id(id, db=db)
# Delete tags if chat is archived
tag_ids = chat.meta.get("tags", [])
if chat.archived:
for tag_id in chat.meta.get("tags", []):
if (
Chats.count_chats_by_tag_name_and_user_id(tag_id, user.id, db=db)
== 0
):
log.debug(f"deleting tag: {tag_id}")
Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
# Archived chats are excluded from count — clean up orphans
Chats.delete_orphan_tags_for_user(tag_ids, user.id, db=db)
else:
for tag_id in chat.meta.get("tags", []):
tag = Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db)
if tag is None:
log.debug(f"inserting tag: {tag_id}")
tag = Tags.insert_new_tag(tag_id, user.id, db=db)
# Unarchived — ensure tag rows exist
Tags.ensure_tags_exist(tag_ids, user.id, db=db)
return ChatResponse(**chat.model_dump())
else:
@@ -1498,11 +1529,9 @@ async def delete_all_tags_by_id(
):
chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if chat:
old_tags = chat.meta.get("tags", [])
Chats.delete_all_tags_by_id_and_user_id(id, user.id, db=db)
for tag in chat.meta.get("tags", []):
if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 0:
Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db)
Chats.delete_orphan_tags_for_user(old_tags, user.id, db=db)
return True
else:
+1 -1
View File
@@ -224,7 +224,7 @@ async def verify_tool_servers_config(
try:
if form_data.type == "mcp":
if form_data.auth_type == "oauth_2.1":
discovery_urls = get_discovery_urls(form_data.url)
discovery_urls = await get_discovery_urls(form_data.url)
for discovery_url in discovery_urls:
log.debug(
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
+56 -25
View File
@@ -38,6 +38,7 @@ from open_webui.models.files import (
from open_webui.models.chats import Chats
from open_webui.models.knowledge import Knowledges
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrants
from open_webui.routers.retrieval import ProcessFileForm, process_file
@@ -47,7 +48,6 @@ from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.utils.misc import strict_match_mime_type
from pydantic import BaseModel
@@ -82,8 +82,13 @@ def has_access_to_file(
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or has_access(
user.id, access_type, knowledge_base.access_control, user_group_ids, db=db
if knowledge_base.user_id == user.id or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
):
return True
@@ -282,7 +287,11 @@ def upload_file_handler(
},
"meta": {
"name": name,
"content_type": file.content_type,
"content_type": (
file.content_type
if isinstance(file.content_type, str)
else None
),
"size": len(contents),
"data": file_metadata,
},
@@ -332,6 +341,8 @@ def upload_file_handler(
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
)
except HTTPException as e:
raise e
except Exception as e:
log.exception(e)
raise HTTPException(
@@ -495,32 +506,35 @@ async def get_file_process_status(
if stream:
MAX_FILE_PROCESSING_DURATION = 3600 * 2
async def event_stream(file_item):
if file_item:
for _ in range(MAX_FILE_PROCESSING_DURATION):
file_item = Files.get_file_by_id(file_item.id, db=db)
if file_item:
data = file_item.model_dump().get("data", {})
status = data.get("status")
async def event_stream(file_id):
# NOTE: We intentionally do NOT capture the request's db session here.
# Each poll creates its own short-lived session to avoid holding a
# connection for hours. A WebSocket push would be more efficient.
for _ in range(MAX_FILE_PROCESSING_DURATION):
file_item = Files.get_file_by_id(file_id) # Creates own session
if file_item:
data = file_item.model_dump().get("data", {})
status = data.get("status")
if status:
event = {"status": status}
if status == "failed":
event["error"] = data.get("error")
if status:
event = {"status": status}
if status == "failed":
event["error"] = data.get("error")
yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"):
break
else:
# Legacy
yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"):
break
else:
# Legacy
break
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
break
await asyncio.sleep(0.5)
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
await asyncio.sleep(1)
return StreamingResponse(
event_stream(file),
event_stream(file.id),
media_type="text/event-stream",
)
else:
@@ -572,7 +586,7 @@ class ContentForm(BaseModel):
@router.post("/{id}/data/content/update")
async def update_file_data_content_by_id(
def update_file_data_content_by_id(
request: Request,
id: str,
form_data: ContentForm,
@@ -822,6 +836,23 @@ async def delete_file_by_id(
or has_access_to_file(id, "write", user, db=db)
):
# Clean up KB associations and embeddings before deleting
knowledges = Knowledges.get_knowledges_by_file_id(id, db=db)
for knowledge in knowledges:
# Remove KB-file relationship
Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db)
# Clean KB embeddings (same logic as /knowledge/{id}/file/remove)
try:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": id}
)
if file.hash:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"hash": file.hash}
)
except Exception as e:
log.debug(f"KB embedding cleanup for {knowledge.id}: {e}")
result = Files.delete_file_by_id(id, db=db)
if result:
try:
-1
View File
@@ -33,7 +33,6 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__)
+9 -3
View File
@@ -19,6 +19,7 @@ from open_webui.utils.plugin import (
load_function_module_by_id,
replace_imports,
get_function_module_from_cache,
resolve_valves_schema_options,
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
@@ -28,7 +29,6 @@ from pydantic import BaseModel, HttpUrl
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
@@ -446,7 +446,10 @@ async def get_function_valves_spec_by_id(
if hasattr(function_module, "Valves"):
Valves = function_module.Valves
return Valves.schema()
schema = Valves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(Valves, schema, user)
return schema
return None
else:
raise HTTPException(
@@ -546,7 +549,10 @@ async def get_function_user_valves_spec_by_id(
if hasattr(function_module, "UserValves"):
UserValves = function_module.UserValves
return UserValves.schema()
schema = UserValves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(UserValves, schema, user)
return schema
return None
else:
raise HTTPException(
+18 -1
View File
@@ -7,6 +7,7 @@ from open_webui.models.users import Users, UserInfoResponse
from open_webui.models.groups import (
Groups,
GroupForm,
GroupInfoResponse,
GroupUpdateForm,
GroupResponse,
UserIdsForm,
@@ -21,7 +22,6 @@ from sqlalchemy.orm import Session
from open_webui.utils.auth import get_admin_user, get_verified_user
log = logging.getLogger(__name__)
router = APIRouter()
@@ -104,6 +104,23 @@ async def get_group_by_id(
)
@router.get("/id/{id}/info", response_model=Optional[GroupInfoResponse])
async def get_group_info_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
group = Groups.get_group_by_id(id, db=db)
if group:
return GroupInfoResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# ExportGroupById
############################
+22 -9
View File
@@ -14,8 +14,13 @@ import requests
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from open_webui.config import CACHE_DIR
from open_webui.config import (
CACHE_DIR,
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN,
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.web.utils import validate_url
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.models.chats import Chats
@@ -198,14 +203,13 @@ async def update_config(
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE
set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
if (
form_data.IMAGE_SIZE == "auto"
and not form_data.IMAGE_GENERATION_MODEL.startswith("gpt-image")
if form_data.IMAGE_SIZE == "auto" and not re.match(
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
):
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT(
" (auto is only allowed with gpt-image models)."
f" (auto is only allowed with models matching {IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN})."
),
)
@@ -609,8 +613,9 @@ async def image_generations(
),
**(
{}
if request.app.state.config.IMAGE_GENERATION_MODEL.startswith(
"gpt-image"
if re.match(
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
request.app.state.config.IMAGE_GENERATION_MODEL,
)
else {"response_format": "b64_json"}
),
@@ -881,6 +886,8 @@ async def image_edits(
return data
if data.startswith("http://") or data.startswith("https://"):
# Validate URL to prevent SSRF attacks against local/private networks
validate_url(data)
r = await asyncio.to_thread(requests.get, data)
r.raise_for_status()
@@ -910,7 +917,10 @@ async def image_edits(
if isinstance(form_data.image, str):
form_data.image = await load_url_image(form_data.image)
elif isinstance(form_data.image, list):
form_data.image = [await load_url_image(img) for img in form_data.image]
# Load all images in parallel for better performance
form_data.image = list(
await asyncio.gather(*[load_url_image(img) for img in form_data.image])
)
except Exception as e:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
@@ -945,7 +955,10 @@ async def image_edits(
**({"size": size} if size else {}),
**(
{}
if request.app.state.config.IMAGE_EDIT_MODEL.startswith("gpt-image")
if re.match(
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
request.app.state.config.IMAGE_EDIT_MODEL,
)
else {"response_format": "b64_json"}
),
}
+206 -47
View File
@@ -29,13 +29,13 @@ from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.models.models import Models, ModelForm
log = logging.getLogger(__name__)
router = APIRouter()
@@ -115,8 +115,10 @@ async def get_knowledge_bases(
skip = (page - 1) * limit
filter = {}
groups = Groups.get_groups_by_member_id(user.id, db=db)
user_group_ids = {group.id for group in groups}
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
@@ -126,6 +128,17 @@ async def get_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit, db=db
)
# Batch-fetch writable knowledge IDs in a single query instead of N has_access calls
knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items]
writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type="knowledge",
resource_ids=knowledge_base_ids,
permission="write",
user_group_ids=user_group_ids,
db=db,
)
return KnowledgeAccessListResponse(
items=[
KnowledgeAccessResponse(
@@ -133,9 +146,7 @@ async def get_knowledge_bases(
write_access=(
user.id == knowledge_base.user_id
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(
user.id, "write", knowledge_base.access_control, db=db
)
or knowledge_base.id in writable_knowledge_base_ids
),
)
for knowledge_base in result.items
@@ -162,8 +173,10 @@ async def search_knowledge_bases(
if view_option:
filter["view_option"] = view_option
groups = Groups.get_groups_by_member_id(user.id, db=db)
user_group_ids = {group.id for group in groups}
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
@@ -173,6 +186,17 @@ async def search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit, db=db
)
# Batch-fetch writable knowledge IDs in a single query instead of N has_access calls
knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items]
writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type="knowledge",
resource_ids=knowledge_base_ids,
permission="write",
user_group_ids=user_group_ids,
db=db,
)
return KnowledgeAccessListResponse(
items=[
KnowledgeAccessResponse(
@@ -180,9 +204,7 @@ async def search_knowledge_bases(
write_access=(
user.id == knowledge_base.user_id
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(
user.id, "write", knowledge_base.access_control, db=db
)
or knowledge_base.id in writable_knowledge_base_ids
),
)
for knowledge_base in result.items
@@ -227,10 +249,13 @@ async def create_new_knowledge(
request: Request,
form_data: KnowledgeForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (has_permission, insert_new_knowledge) manage their own sessions.
# This prevents holding a connection during embed_knowledge_base_metadata()
# which makes external embedding API calls (1-5+ seconds).
if user.role != "admin" and not has_permission(
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS, db=db
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -240,17 +265,16 @@ async def create_new_knowledge(
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
form_data.access_control = {}
form_data.access_grants = []
knowledge = Knowledges.insert_new_knowledge(user.id, form_data, db=db)
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
if knowledge:
# Embed knowledge base for semantic search
@@ -345,10 +369,15 @@ async def reindex_knowledge_files(
async def reindex_knowledge_base_metadata_embeddings(
request: Request,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Batch embed all existing knowledge bases. Admin only."""
knowledge_bases = Knowledges.get_knowledge_bases(db=db)
"""Batch embed all existing knowledge bases. Admin only.
NOTE: We intentionally do NOT use Depends(get_session) here.
This endpoint loops through ALL knowledge bases and calls embed_knowledge_base_metadata()
for each one, making N external embedding API calls. Holding a session during
this entire operation would exhaust the connection pool.
"""
knowledge_bases = Knowledges.get_knowledge_bases()
log.info(f"Reindexing embeddings for {len(knowledge_bases)} knowledge bases")
success_count = 0
@@ -380,7 +409,13 @@ async def get_knowledge_by_id(
if (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="read",
db=db,
)
):
return KnowledgeFilesResponse(
@@ -388,7 +423,13 @@ async def get_knowledge_by_id(
write_access=(
user.id == knowledge.user_id
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(user.id, "write", knowledge.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
),
)
else:
@@ -414,9 +455,12 @@ async def update_knowledge_by_id(
id: str,
form_data: KnowledgeForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations manage their own short-lived sessions internally.
# This prevents holding a connection during embed_knowledge_base_metadata()
# which makes external embedding API calls (1-5+ seconds).
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -425,7 +469,12 @@ async def update_knowledge_by_id(
# Is the user the original creator, in a group with write access, or an admin
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
)
and user.role != "admin"
):
raise HTTPException(
@@ -436,17 +485,16 @@ async def update_knowledge_by_id(
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
form_data.access_control = {}
form_data.access_grants = []
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data, db=db)
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
# Re-embed knowledge base for semantic search
await embed_knowledge_base_metadata(
@@ -457,7 +505,7 @@ async def update_knowledge_by_id(
)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
)
else:
raise HTTPException(
@@ -466,6 +514,73 @@ async def update_knowledge_by_id(
)
############################
# UpdateKnowledgeAccessById
############################
class KnowledgeAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post("/{id}/access/update", response_model=Optional[KnowledgeFilesResponse])
async def update_knowledge_access_by_id(
request: Request,
id: str,
form_data: KnowledgeAccessGrantsForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
knowledge.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db)
return KnowledgeFilesResponse(
**Knowledges.get_knowledge_by_id(id=id, db=db).model_dump(),
files=Knowledges.get_file_metadatas_by_id(id, db=db),
)
############################
# GetKnowledgeFilesById
############################
@@ -493,7 +608,13 @@ async def get_knowledge_files_by_id(
if not (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="read",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -546,7 +667,13 @@ def add_file_to_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -615,7 +742,13 @@ def update_file_from_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
@@ -684,7 +817,13 @@ def remove_file_from_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -761,7 +900,13 @@ async def delete_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -793,7 +938,7 @@ async def delete_knowledge_by_id(
base_model_id=model.base_model_id,
meta=model.meta,
params=model.params,
access_control=model.access_control,
access_grants=model.access_grants,
is_active=model.is_active,
)
Models.update_model_by_id(model.id, model_form, db=db)
@@ -830,7 +975,13 @@ async def reset_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -873,7 +1024,13 @@ async def add_files_to_knowledge_batch(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -881,17 +1038,19 @@ async def add_files_to_knowledge_batch(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Get files content
# Batch-fetch all files to avoid N+1 queries
log.info(f"files/batch/add - {len(form_data)} files")
files: List[FileModel] = []
for form in form_data:
file = Files.get_file_by_id(form.file_id, db=db)
if not file:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File {form.file_id} not found",
)
files.append(file)
file_ids = [form.file_id for form in form_data]
files = Files.get_files_by_ids(file_ids, db=db)
# Verify all requested files were found
found_ids = {file.id for file in files}
missing_ids = [fid for fid in file_ids if fid not in found_ids]
if missing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File {missing_ids[0]} not found",
)
# Process files
try:
+24 -13
View File
@@ -18,11 +18,6 @@ log = logging.getLogger(__name__)
router = APIRouter()
@router.get("/ef")
async def get_embeddings(request: Request):
return {"result": await request.app.state.EMBEDDING_FUNCTION("hello world")}
############################
# GetMemories
############################
@@ -69,8 +64,11 @@ async def add_memory(
request: Request,
form_data: AddMemoryForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (insert_new_memory) manage their own short-lived sessions.
# This prevents holding a connection during EMBEDDING_FUNCTION()
# which makes external embedding API calls (1-5+ seconds).
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -85,7 +83,7 @@ async def add_memory(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memory = Memories.insert_new_memory(user.id, form_data.content, db=db)
memory = Memories.insert_new_memory(user.id, form_data.content)
vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
@@ -119,8 +117,11 @@ async def query_memory(
request: Request,
form_data: QueryMemoryForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_memories_by_user_id) manage their own short-lived sessions.
# This prevents holding a connection during EMBEDDING_FUNCTION()
# which makes external embedding API calls (1-5+ seconds).
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -135,7 +136,7 @@ async def query_memory(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memories = Memories.get_memories_by_user_id(user.id, db=db)
memories = Memories.get_memories_by_user_id(user.id)
if not memories:
raise HTTPException(status_code=404, detail="No memories found for user")
@@ -157,8 +158,15 @@ async def query_memory(
async def reset_memory_from_vector_db(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Reset user's memory vector embeddings.
CRITICAL: We intentionally do NOT use Depends(get_session) here.
This endpoint generates embeddings for ALL user memories in parallel using
asyncio.gather(). A user with 100 memories would trigger 100 embedding API
calls simultaneously. With a session held, this could block a connection
for MINUTES, completely exhausting the connection pool.
"""
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -175,7 +183,7 @@ async def reset_memory_from_vector_db(
VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}")
memories = Memories.get_memories_by_user_id(user.id, db=db)
memories = Memories.get_memories_by_user_id(user.id)
# Generate vectors in parallel
vectors = await asyncio.gather(
@@ -252,8 +260,11 @@ async def update_memory_by_id(
request: Request,
form_data: MemoryUpdateModel,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (update_memory_by_id_and_user_id) manage their own
# short-lived sessions. This prevents holding a connection during
# EMBEDDING_FUNCTION() which makes external API calls (1-5+ seconds).
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -269,7 +280,7 @@ async def update_memory_by_id(
)
memory = Memories.update_memory_by_id_and_user_id(
memory_id, user.id, form_data.content, db=db
memory_id, user.id, form_data.content
)
if memory is None:
raise HTTPException(status_code=404, detail="Memory not found")
+157 -13
View File
@@ -8,13 +8,16 @@ import logging
from open_webui.models.groups import Groups
from open_webui.models.models import (
ModelForm,
ModelMeta,
ModelModel,
ModelParams,
ModelResponse,
ModelListResponse,
ModelAccessListResponse,
ModelAccessResponse,
Models,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
@@ -30,7 +33,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@@ -83,14 +86,29 @@ async def get_models(
if direction:
filter["direction"] = direction
# Pre-fetch user group IDs once - used for both filter and write_access check
groups = Groups.get_groups_by_member_id(user.id, db=db)
user_group_ids = {group.id for group in groups}
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db)
# Batch-fetch writable model IDs in a single query instead of N has_access calls
model_ids = [model.id for model in result.items]
writable_model_ids = AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type="model",
resource_ids=model_ids,
permission="write",
user_group_ids=user_group_ids,
db=db,
)
return ModelAccessListResponse(
items=[
ModelAccessResponse(
@@ -98,7 +116,7 @@ async def get_models(
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
or model.id in writable_model_ids
),
)
for model in result.items
@@ -246,12 +264,25 @@ async def import_models(
try:
data = form_data.models
if isinstance(data, list):
# Batch-fetch all existing models in one query to avoid N+1
model_ids = [
model_data.get("id")
for model_data in data
if model_data.get("id") and is_valid_model_id(model_data.get("id"))
]
existing_models = {
model.id: model
for model in (
Models.get_models_by_ids(model_ids, db=db) if model_ids else []
)
}
for model_data in data:
# Here, you can add logic to validate model_data if needed
model_id = model_data.get("id")
if model_id and is_valid_model_id(model_id):
existing_model = Models.get_model_by_id(model_id, db=db)
existing_model = existing_models.get(model_id)
if existing_model:
# Update existing model
model_data["meta"] = model_data.get("meta", {})
@@ -315,14 +346,26 @@ async def get_model_by_id(
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or model.user_id == user.id
or has_access(user.id, "read", model.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="read",
db=db,
)
):
return ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
),
)
else:
@@ -343,10 +386,8 @@ async def get_model_by_id(
@router.get("/model/profile/image")
def get_model_profile_image(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
model = Models.get_model_by_id(id, db=db)
def get_model_profile_image(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
if model:
etag = f'"{model.updated_at}"' if model.updated_at else None
@@ -395,7 +436,13 @@ async def toggle_model_by_id(
if (
user.role == "admin"
or model.user_id == user.id
or has_access(user.id, "write", model.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
):
model = Models.toggle_model_by_id(id, db=db)
@@ -438,7 +485,13 @@ async def update_model_by_id(
if (
model.user_id != user.id
and not has_access(user.id, "write", model.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -452,6 +505,91 @@ async def update_model_by_id(
return model
############################
# UpdateModelAccessById
############################
class ModelAccessGrantsForm(BaseModel):
id: str
access_grants: list[dict]
@router.post("/model/access/update", response_model=Optional[ModelModel])
async def update_model_access_by_id(
request: Request,
form_data: ModelAccessGrantsForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
model = Models.get_model_by_id(form_data.id, db=db)
# Non-preset models (e.g. direct Ollama/OpenAI models) may not have a DB
# entry yet. Create a minimal one so access grants can be stored.
if not model:
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
model = Models.insert_new_model(
ModelForm(
id=form_data.id,
name=form_data.id,
meta=ModelMeta(),
params=ModelParams(),
),
user.id,
db=db,
)
if not model:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT("Error creating model entry"),
)
if (
model.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_models",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
AccessGrants.set_access_grants(
"model", form_data.id, form_data.access_grants, db=db
)
return Models.get_model_by_id(form_data.id, db=db)
############################
# DeleteModelById
############################
@@ -473,7 +611,13 @@ async def delete_model_by_id(
if (
user.role != "admin"
and model.user_id != user.id
and not has_access(user.id, "write", model.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+97 -14
View File
@@ -27,7 +27,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@@ -200,8 +201,12 @@ async def get_note_by_id(
if user.role != "admin" and (
user.id != note.user_id
and (
not has_access(
user.id, type="read", access_control=note.access_control, db=db
not AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="read",
db=db,
)
)
):
@@ -212,13 +217,14 @@ async def get_note_by_id(
write_access = (
user.role == "admin"
or (user.id == note.user_id)
or has_access(
user.id,
type="write",
access_control=note.access_control,
strict=False,
or AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="write",
db=db,
)
or has_public_read_access_grant(note.access_grants)
)
return NoteResponse(**note.model_dump(), write_access=write_access)
@@ -253,8 +259,12 @@ async def update_note_by_id(
if user.role != "admin" and (
user.id != note.user_id
and not has_access(
user.id, type="write", access_control=note.access_control, db=db
and not AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="write",
db=db,
)
):
raise HTTPException(
@@ -264,7 +274,7 @@ async def update_note_by_id(
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_notes",
@@ -272,7 +282,7 @@ async def update_note_by_id(
db=db,
)
):
form_data.access_control = {}
form_data.access_grants = []
try:
note = Notes.update_note_by_id(id, form_data, db=db)
@@ -290,6 +300,75 @@ async def update_note_by_id(
)
############################
# UpdateNoteAccessById
############################
class NoteAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post("/{id}/access/update", response_model=Optional[NoteModel])
async def update_note_access_by_id(
request: Request,
id: str,
form_data: NoteAccessGrantsForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and (
user.id != note.user_id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="write",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db)
return Notes.get_note_by_id(id, db=db)
############################
# DeleteNoteById
############################
@@ -318,8 +397,12 @@ async def delete_note_by_id(
if user.role != "admin" and (
user.id != note.user_id
and not has_access(
user.id, type="write", access_control=note.access_control, db=db
and not AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="write",
db=db,
)
):
raise HTTPException(
+140 -84
View File
@@ -23,6 +23,7 @@ from open_webui.models.users import UserModel
from open_webui.env import (
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
)
from fastapi import (
@@ -37,15 +38,19 @@ from fastapi import (
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, validator
from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.models import Models
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
from open_webui.utils.misc import (
calculate_sha256,
cleanup_response,
stream_wrapper,
)
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
@@ -53,9 +58,6 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.config import (
UPLOAD_DIR,
)
@@ -103,16 +105,6 @@ async def send_get_request(url, key=None, user: UserModel = None):
return None
async def cleanup_response(
response: Optional[aiohttp.ClientResponse],
session: Optional[aiohttp.ClientSession],
):
if response:
response.close()
if session:
await session.close()
async def send_post_request(
url: str,
payload: Union[str, bytes],
@@ -124,6 +116,7 @@ async def send_post_request(
):
r = None
streaming = False
try:
session = aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
@@ -137,7 +130,7 @@ async def send_post_request(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get("chat_id"):
headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
r = await session.post(
url,
@@ -168,13 +161,11 @@ async def send_post_request(
if content_type:
response_headers["Content-Type"] = content_type
streaming = True
return StreamingResponse(
r.content,
stream_wrapper(r, session),
status_code=r.status,
headers=response_headers,
background=BackgroundTask(
cleanup_response, response=r, session=session
),
)
else:
res = await r.json()
@@ -190,7 +181,7 @@ async def send_post_request(
detail=detail if e else "Open WebUI: Server Connection Error",
)
finally:
if not stream:
if not streaming:
await cleanup_response(r, session)
@@ -426,13 +417,30 @@ async def get_all_models(request: Request, user: UserModel = None):
async def get_filtered_models(models, user, db=None):
# Filter models based on user access control
model_ids = [model["model"] for model in models.get("models", [])]
model_infos = {
model_info.id: model_info
for model_info in Models.get_models_by_ids(model_ids, db=db)
}
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
accessible_model_ids = AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type="model",
resource_ids=list(model_infos.keys()),
permission="read",
user_group_ids=user_group_ids,
db=db,
)
filtered_models = []
for model in models.get("models", []):
model_info = Models.get_model_by_id(model["model"], db=db)
model_info = model_infos.get(model["model"])
if model_info:
if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control, db=db
):
if user.id == model_info.user_id or model_info.id in accessible_model_ids:
filtered_models.append(model)
return filtered_models
@@ -442,6 +450,9 @@ async def get_filtered_models(models, user, db=None):
async def get_ollama_tags(
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
models = []
if url_idx is None:
@@ -650,10 +661,6 @@ async def unload_model(
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
# Canonicalize model name (if not supplied with version)
if ":" not in model_name:
model_name = f"{model_name}:latest"
if model_name not in models:
raise HTTPException(
status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name)
@@ -706,6 +713,9 @@ async def pull_model(
url_idx: int = 0,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
form_data = form_data.model_dump(exclude_none=True)
form_data["model"] = form_data.get("model", form_data.get("name"))
@@ -737,6 +747,9 @@ async def push_model(
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
@@ -776,6 +789,9 @@ async def create_model(
url_idx: int = 0,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
log.debug(f"form_data: {form_data}")
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
@@ -800,6 +816,9 @@ async def copy_model(
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
@@ -860,6 +879,9 @@ async def delete_model(
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
form_data = form_data.model_dump(exclude_none=True)
form_data["model"] = form_data.get("model", form_data.get("name"))
@@ -922,6 +944,9 @@ async def delete_model(
async def show_model_info(
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
form_data = form_data.model_dump(exclude_none=True)
form_data["model"] = form_data.get("model", form_data.get("name"))
@@ -994,16 +1019,19 @@ async def embed(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
log.info(f"generate_ollama_batch_embeddings {form_data}")
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
model = form_data.model
if ":" not in model:
model = f"{model}:latest"
# Check if model is already in app state cache to avoid expensive get_all_models() call
models = request.app.state.OLLAMA_MODELS
if not models or model not in models:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model in models:
url_idx = random.choice(models[model]["urls"])
@@ -1076,16 +1104,19 @@ async def embeddings(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
log.info(f"generate_ollama_embeddings {form_data}")
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
model = form_data.model
if ":" not in model:
model = f"{model}:latest"
# Check if model is already in app state cache to avoid expensive get_all_models() call
models = request.app.state.OLLAMA_MODELS
if not models or model not in models:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
if model in models:
url_idx = random.choice(models[model]["urls"])
@@ -1166,15 +1197,14 @@ async def generate_completion(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
model = form_data.model
if ":" not in model:
model = f"{model}:latest"
if model in models:
url_idx = random.choice(models[model]["urls"])
else:
@@ -1257,8 +1287,14 @@ async def generate_chat_completion(
user=Depends(get_verified_user),
bypass_filter: Optional[bool] = False,
bypass_system_prompt: bool = False,
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
@@ -1279,7 +1315,7 @@ async def generate_chat_completion(
del payload["metadata"]
model_id = payload["model"]
model_info = Models.get_model_by_id(model_id, db=db)
model_info = Models.get_model_by_id(model_id)
if model_info:
if model_info.base_model_id:
@@ -1301,13 +1337,17 @@ async def generate_chat_completion(
# Check if user has access to the model
if not bypass_filter and user.role == "user":
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id)
}
if not (
user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
db=db,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
)
):
raise HTTPException(
@@ -1321,9 +1361,6 @@ async def generate_chat_completion(
detail="Model not found",
)
if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest"
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
@@ -1379,8 +1416,11 @@ async def generate_openai_completion(
form_data: dict,
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
metadata = form_data.pop("metadata", None)
try:
@@ -1397,10 +1437,7 @@ async def generate_openai_completion(
del payload["metadata"]
model_id = form_data.model
if ":" not in model_id:
model_id = f"{model_id}:latest"
model_info = Models.get_model_by_id(model_id, db=db)
model_info = Models.get_model_by_id(model_id)
if model_info:
if model_info.base_model_id:
payload["model"] = model_info.base_model_id
@@ -1411,13 +1448,17 @@ async def generate_openai_completion(
# Check if user has access to the model
if user.role == "user":
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id)
}
if not (
user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
db=db,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
)
):
raise HTTPException(
@@ -1431,9 +1472,6 @@ async def generate_openai_completion(
detail="Model not found",
)
if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest"
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
@@ -1462,8 +1500,11 @@ async def generate_openai_chat_completion(
form_data: dict,
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
metadata = form_data.pop("metadata", None)
try:
@@ -1480,10 +1521,7 @@ async def generate_openai_chat_completion(
del payload["metadata"]
model_id = completion_form.model
if ":" not in model_id:
model_id = f"{model_id}:latest"
model_info = Models.get_model_by_id(model_id, db=db)
model_info = Models.get_model_by_id(model_id)
if model_info:
if model_info.base_model_id:
payload["model"] = model_info.base_model_id
@@ -1498,13 +1536,17 @@ async def generate_openai_chat_completion(
# Check if user has access to the model
if user.role == "user":
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id)
}
if not (
user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
db=db,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
)
):
raise HTTPException(
@@ -1518,9 +1560,6 @@ async def generate_openai_chat_completion(
detail="Model not found",
)
if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest"
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
@@ -1598,15 +1637,32 @@ async def get_openai_models(
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
# Filter models based on user access control
model_ids = [model["id"] for model in models]
model_infos = {
model_info.id: model_info
for model_info in Models.get_models_by_ids(model_ids, db=db)
}
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
accessible_model_ids = AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type="model",
resource_ids=list(model_infos.keys()),
permission="read",
user_group_ids=user_group_ids,
db=db,
)
filtered_models = []
for model in models:
model_info = Models.get_model_by_id(model["id"], db=db)
model_info = model_infos.get(model["id"])
if model_info:
if user.id == model_info.user_id or has_access(
user.id,
type="read",
access_control=model_info.access_control,
db=db,
if (
user.id == model_info.user_id
or model_info.id in accessible_model_ids
):
filtered_models.append(model)
models = filtered_models
+363 -70
View File
@@ -3,6 +3,7 @@ import hashlib
import json
import logging
from typing import Optional
from urllib.parse import urlparse
import aiohttp
from aiocache import cached
@@ -17,13 +18,15 @@ from fastapi.responses import (
JSONResponse,
PlainTextResponse,
)
from pydantic import BaseModel
from starlette.background import BackgroundTask
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.models import Models
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
from open_webui.config import (
CACHE_DIR,
)
@@ -33,6 +36,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
BYPASS_MODEL_ACCESS_CONTROL,
)
from open_webui.models.users import UserModel
@@ -45,15 +49,15 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
from open_webui.utils.misc import (
cleanup_response,
convert_logit_bias_input_to_json,
stream_chunks_handler,
stream_wrapper,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
@@ -87,16 +91,6 @@ async def send_get_request(url, key=None, user: UserModel = None):
return None
async def cleanup_response(
response: Optional[aiohttp.ClientResponse],
session: Optional[aiohttp.ClientSession],
):
if response:
response.close()
if session:
await session.close()
def openai_reasoning_model_handler(payload):
"""
Handle reasoning model specific parameters
@@ -142,7 +136,7 @@ async def get_headers_and_cookies(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get("chat_id"):
headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
token = None
auth_type = config.get("auth_type")
@@ -347,37 +341,41 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
if not request.app.state.config.ENABLE_OPENAI_API:
return []
# Cache config values locally to avoid repeated Redis lookups.
# Each access to request.app.state.config.<KEY> triggers a Redis GET;
# caching here avoids hundreds of redundant round-trips.
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
api_keys = list(request.app.state.config.OPENAI_API_KEYS)
api_configs = request.app.state.config.OPENAI_API_CONFIGS
# Check if API KEYS length is same than API URLS length
num_urls = len(request.app.state.config.OPENAI_API_BASE_URLS)
num_keys = len(request.app.state.config.OPENAI_API_KEYS)
num_urls = len(api_base_urls)
num_keys = len(api_keys)
if num_keys != num_urls:
# if there are more keys than urls, remove the extra keys
if num_keys > num_urls:
new_keys = request.app.state.config.OPENAI_API_KEYS[:num_urls]
request.app.state.config.OPENAI_API_KEYS = new_keys
api_keys = api_keys[:num_urls]
request.app.state.config.OPENAI_API_KEYS = api_keys
# if there are more urls than keys, add empty keys
else:
request.app.state.config.OPENAI_API_KEYS += [""] * (num_urls - num_keys)
api_keys += [""] * (num_urls - num_keys)
request.app.state.config.OPENAI_API_KEYS = api_keys
request_tasks = []
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
if (str(idx) not in request.app.state.config.OPENAI_API_CONFIGS) and (
url not in request.app.state.config.OPENAI_API_CONFIGS # Legacy support
):
for idx, url in enumerate(api_base_urls):
if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support
request_tasks.append(
send_get_request(
f"{url}/models",
request.app.state.config.OPENAI_API_KEYS[idx],
api_keys[idx],
user=user,
)
)
else:
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
api_config = api_configs.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
url, {}
), # Legacy support
api_configs.get(url, {}), # Legacy support
)
enable = api_config.get("enable", True)
@@ -388,7 +386,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
request_tasks.append(
send_get_request(
f"{url}/models",
request.app.state.config.OPENAI_API_KEYS[idx],
api_keys[idx],
user=user,
)
)
@@ -417,12 +415,10 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
for idx, response in enumerate(responses):
if response:
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
url = api_base_urls[idx]
api_config = api_configs.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
url, {}
), # Legacy support
api_configs.get(url, {}), # Legacy support
)
connection_type = api_config.get("connection_type", "external")
@@ -458,13 +454,30 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
async def get_filtered_models(models, user, db=None):
# Filter models based on user access control
model_ids = [model["id"] for model in models.get("data", [])]
model_infos = {
model_info.id: model_info
for model_info in Models.get_models_by_ids(model_ids, db=db)
}
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
accessible_model_ids = AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type="model",
resource_ids=list(model_infos.keys()),
permission="read",
user_group_ids=user_group_ids,
db=db,
)
filtered_models = []
for model in models.get("data", []):
model_info = Models.get_model_by_id(model["id"], db=db)
model_info = model_infos.get(model["id"])
if model_info:
if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control, db=db
):
if user.id == model_info.user_id or model_info.id in accessible_model_ids:
filtered_models.append(model)
return filtered_models
@@ -479,6 +492,10 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
if not request.app.state.config.ENABLE_OPENAI_API:
return {"data": []}
# Cache config value locally to avoid repeated Redis lookups inside
# the nested loop in get_merged_models (one GET per model otherwise).
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
responses = await get_all_models_responses(request, user=user)
def extract_data(response):
@@ -512,10 +529,10 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
for model in model_list:
model_id = model.get("id") or model.get("name")
if (
"api.openai.com"
in request.app.state.config.OPENAI_API_BASE_URLS[idx]
and not is_supported_openai_models(model_id)
base_url = api_base_urls[idx]
hostname = urlparse(base_url).hostname if base_url else None
if hostname == "api.openai.com" and not is_supported_openai_models(
model_id
):
# Skip unwanted OpenAI models
continue
@@ -544,6 +561,9 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
async def get_models(
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
):
if not request.app.state.config.ENABLE_OPENAI_API:
raise HTTPException(status_code=503, detail="OpenAI API is disabled")
models = {
"data": [],
}
@@ -584,9 +604,12 @@ async def get_models(
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
try:
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
except Exception:
pass
raise Exception(error_detail)
response_data = await r.json()
@@ -794,6 +817,115 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
return url, payload
def convert_to_responses_payload(payload: dict) -> dict:
"""
Convert Chat Completions payload to Responses API format.
Chat Completions: { messages: [{role, content}], ... }
Responses API: { input: [{type: "message", role, content: [...]}], instructions: "system" }
"""
messages = payload.pop("messages", [])
system_content = ""
input_items = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
# Check for stored output items (from previous Responses API turn)
stored_output = msg.get("output")
if stored_output and isinstance(stored_output, list):
input_items.extend(stored_output)
continue
if role == "system":
if isinstance(content, str):
system_content = content
elif isinstance(content, list):
system_content = "\n".join(
p.get("text", "") for p in content if p.get("type") == "text"
)
continue
# Convert content format
text_type = "output_text" if role == "assistant" else "input_text"
if isinstance(content, str):
content_parts = [{"type": text_type, "text": content}]
elif isinstance(content, list):
content_parts = []
for part in content:
if part.get("type") == "text":
content_parts.append(
{"type": text_type, "text": part.get("text", "")}
)
elif part.get("type") == "image_url":
url_data = part.get("image_url", {})
url = (
url_data.get("url", "")
if isinstance(url_data, dict)
else url_data
)
content_parts.append({"type": "input_image", "image_url": url})
else:
content_parts = [{"type": text_type, "text": str(content)}]
input_items.append({"type": "message", "role": role, "content": content_parts})
responses_payload = {**payload, "input": input_items}
if system_content:
responses_payload["instructions"] = system_content
if "max_tokens" in responses_payload:
responses_payload["max_output_tokens"] = responses_payload.pop("max_tokens")
# Remove Chat Completions-only parameters not supported by the Responses API
for unsupported_key in (
"stream_options",
"logit_bias",
"frequency_penalty",
"presence_penalty",
"stop",
):
responses_payload.pop(unsupported_key, None)
# Convert Chat Completions tools format to Responses API format
# Chat Completions: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
# Responses API: {"type": "function", "name": ..., "description": ..., "parameters": ...}
if "tools" in responses_payload and isinstance(responses_payload["tools"], list):
converted_tools = []
for tool in responses_payload["tools"]:
if isinstance(tool, dict) and "function" in tool:
func = tool["function"]
converted_tool = {"type": tool.get("type", "function")}
if isinstance(func, dict):
converted_tool["name"] = func.get("name", "")
if "description" in func:
converted_tool["description"] = func["description"]
if "parameters" in func:
converted_tool["parameters"] = func["parameters"]
if "strict" in func:
converted_tool["strict"] = func["strict"]
converted_tools.append(converted_tool)
else:
# Already in correct format or unknown format, pass through
converted_tools.append(tool)
responses_payload["tools"] = converted_tools
return responses_payload
def convert_responses_result(response: dict) -> dict:
"""
Convert non-streaming Responses API result.
Just add done flag - pass through raw response, frontend handles output.
"""
response["done"] = True
return response
@router.post("/chat/completions")
async def generate_chat_completion(
request: Request,
@@ -801,8 +933,11 @@ async def generate_chat_completion(
user=Depends(get_verified_user),
bypass_filter: Optional[bool] = False,
bypass_system_prompt: bool = False,
db: Session = Depends(get_session),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
@@ -812,7 +947,7 @@ async def generate_chat_completion(
metadata = payload.pop("metadata", None)
model_id = form_data.get("model")
model_info = Models.get_model_by_id(model_id, db=db)
model_info = Models.get_model_by_id(model_id)
# Check model info and override the payload
if model_info:
@@ -836,13 +971,17 @@ async def generate_chat_completion(
# Check if user has access to the model
if not bypass_filter and user.role == "user":
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id)
}
if not (
user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
db=db,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
)
):
raise HTTPException(
@@ -856,8 +995,13 @@ async def generate_chat_completion(
detail="Model not found",
)
await get_all_models(request, user=user)
model = request.app.state.OPENAI_MODELS.get(model_id)
# Check if model is already in app state cache to avoid expensive get_all_models() call
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
model = models.get(model_id)
if model:
idx = model["urlIdx"]
else:
@@ -913,6 +1057,8 @@ async def generate_chat_completion(
request, url, key, api_config, metadata, user=user
)
is_responses = api_config.get("api_type") == "responses"
if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview")
request_url, payload = convert_to_azure_payload(url, payload, api_version)
@@ -923,9 +1069,18 @@ async def generate_chat_completion(
headers["api-key"] = key
headers["api-version"] = api_version
request_url = f"{request_url}/chat/completions?api-version={api_version}"
if is_responses:
payload = convert_to_responses_payload(payload)
request_url = f"{request_url}/responses?api-version={api_version}"
else:
request_url = f"{request_url}/chat/completions?api-version={api_version}"
else:
request_url = f"{url}/chat/completions"
if is_responses:
payload = convert_to_responses_payload(payload)
request_url = f"{url}/responses"
else:
request_url = f"{url}/chat/completions"
payload = json.dumps(payload)
@@ -952,12 +1107,9 @@ async def generate_chat_completion(
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
stream_chunks_handler(r.content),
stream_wrapper(r, session, stream_chunks_handler),
status_code=r.status,
headers=dict(r.headers),
background=BackgroundTask(
cleanup_response, response=r, session=session
),
)
else:
try:
@@ -972,6 +1124,10 @@ async def generate_chat_completion(
else:
return PlainTextResponse(status_code=r.status, content=response)
# Convert Responses API result to simple format
if is_responses and isinstance(response, dict):
response = convert_responses_result(response)
return response
except Exception as e:
log.exception(e)
@@ -1001,9 +1157,12 @@ async def embeddings(request: Request, form_data: dict, user):
# Prepare payload/body
body = json.dumps(form_data)
# Find correct backend url/key based on model
await get_all_models(request, user=user)
model_id = form_data.get("model")
# Check if model is already in app state cache to avoid expensive get_all_models() call
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
if model_id in models:
idx = models[model_id]["urlIdx"]
@@ -1034,12 +1193,9 @@ async def embeddings(request: Request, form_data: dict, user):
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
r.content,
stream_wrapper(r, session),
status_code=r.status,
headers=dict(r.headers),
background=BackgroundTask(
cleanup_response, response=r, session=session
),
)
else:
try:
@@ -1067,6 +1223,129 @@ async def embeddings(request: Request, form_data: dict, user):
await cleanup_response(r, session)
class ResponsesForm(BaseModel):
model_config = ConfigDict(extra="allow")
model: str
input: Optional[list | str] = None
instructions: Optional[str] = None
stream: Optional[bool] = None
temperature: Optional[float] = None
max_output_tokens: Optional[int] = None
top_p: Optional[float] = None
tools: Optional[list] = None
tool_choice: Optional[str | dict] = None
text: Optional[dict] = None
truncation: Optional[str] = None
metadata: Optional[dict] = None
store: Optional[bool] = None
reasoning: Optional[dict] = None
previous_response_id: Optional[str] = None
@router.post("/responses")
async def responses(
request: Request,
form_data: ResponsesForm,
user=Depends(get_verified_user),
):
"""
Forward requests to the OpenAI Responses API endpoint.
Routes to the correct upstream backend based on the model field.
"""
payload = form_data.model_dump(exclude_none=True)
body = json.dumps(payload)
idx = 0
model_id = form_data.model
if model_id:
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
if model_id in models:
idx = models[model_id]["urlIdx"]
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
r = None
session = None
streaming = False
try:
headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user
)
if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview")
auth_type = api_config.get("auth_type", "bearer")
if auth_type not in ("azure_ad", "microsoft_entra_id"):
headers["api-key"] = key
headers["api-version"] = api_version
model = payload.get("model", "")
request_url = (
f"{url}/openai/deployments/{model}/responses?api-version={api_version}"
)
else:
request_url = f"{url}/responses"
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
r = await session.request(
method="POST",
url=request_url,
data=body,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
# Check if response is SSE
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
stream_wrapper(r, session),
status_code=r.status,
headers=dict(r.headers),
)
else:
try:
response_data = await r.json()
except Exception:
response_data = await r.text()
if r.status >= 400:
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=r.status, content=response_data)
else:
return PlainTextResponse(
status_code=r.status, content=response_data
)
return response_data
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=r.status if r else 500,
detail="Open WebUI: Server Connection Error",
)
finally:
if not streaming:
await cleanup_response(r, session)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
"""
@@ -1075,7 +1354,24 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
body = await request.body()
# Parse JSON body to resolve model-based routing
payload = None
if body:
try:
payload = json.loads(body)
except (json.JSONDecodeError, ValueError):
payload = None
idx = 0
model_id = payload.get("model") if isinstance(payload, dict) else None
if model_id:
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
if model_id in models:
idx = models[model_id]["urlIdx"]
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
@@ -1126,12 +1422,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
r.content,
stream_wrapper(r, session),
status_code=r.status,
headers=dict(r.headers),
background=BackgroundTask(
cleanup_response, response=r, session=session
),
)
else:
try:
+110 -79
View File
@@ -13,7 +13,6 @@ import aiohttp
import os
import logging
import shutil
import requests
from pydantic import BaseModel
from starlette.responses import FileResponse
from typing import Optional
@@ -217,7 +216,7 @@ async def upload_pipeline(
os.makedirs(upload_folder, exist_ok=True)
file_path = os.path.join(upload_folder, filename)
r = None
response = None
try:
# Save the uploaded file
with open(file_path, "wb") as buffer:
@@ -226,16 +225,26 @@ async def upload_pipeline(
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
with open(file_path, "rb") as f:
files = {"file": f}
r = requests.post(
f"{url}/pipelines/upload",
headers={"Authorization": f"Bearer {key}"},
files=files,
)
headers = {"Authorization": f"Bearer {key}"}
r.raise_for_status()
data = r.json()
async with aiohttp.ClientSession(trust_env=True) as session:
with open(file_path, "rb") as f:
form_data = aiohttp.FormData()
form_data.add_field(
"file",
f,
filename=filename,
content_type="application/octet-stream",
)
async with session.post(
f"{url}/pipelines/upload",
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
return {**data}
except Exception as e:
@@ -244,10 +253,10 @@ async def upload_pipeline(
detail = None
status_code = status.HTTP_404_NOT_FOUND
if r is not None:
status_code = r.status_code
if response is not None:
status_code = response.status
try:
res = r.json()
res = await response.json()
if "detail" in res:
detail = res["detail"]
except Exception:
@@ -272,21 +281,22 @@ class AddPipelineForm(BaseModel):
async def add_pipeline(
request: Request, form_data: AddPipelineForm, user=Depends(get_admin_user)
):
r = None
response = None
try:
urlIdx = form_data.urlIdx
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
r = requests.post(
f"{url}/pipelines/add",
headers={"Authorization": f"Bearer {key}"},
json={"url": form_data.url},
)
r.raise_for_status()
data = r.json()
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
f"{url}/pipelines/add",
headers={"Authorization": f"Bearer {key}"},
json={"url": form_data.url},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
return {**data}
except Exception as e:
@@ -294,16 +304,18 @@ async def add_pipeline(
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
if response is not None:
try:
res = r.json()
res = await response.json()
if "detail" in res:
detail = res["detail"]
except Exception:
pass
raise HTTPException(
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
status_code=(
response.status if response is not None else status.HTTP_404_NOT_FOUND
),
detail=detail if detail else "Pipeline not found",
)
@@ -317,21 +329,22 @@ class DeletePipelineForm(BaseModel):
async def delete_pipeline(
request: Request, form_data: DeletePipelineForm, user=Depends(get_admin_user)
):
r = None
response = None
try:
urlIdx = form_data.urlIdx
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
r = requests.delete(
f"{url}/pipelines/delete",
headers={"Authorization": f"Bearer {key}"},
json={"id": form_data.id},
)
r.raise_for_status()
data = r.json()
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.delete(
f"{url}/pipelines/delete",
headers={"Authorization": f"Bearer {key}"},
json={"id": form_data.id},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
return {**data}
except Exception as e:
@@ -339,16 +352,18 @@ async def delete_pipeline(
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
if response is not None:
try:
res = r.json()
res = await response.json()
if "detail" in res:
detail = res["detail"]
except Exception:
pass
raise HTTPException(
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
status_code=(
response.status if response is not None else status.HTTP_404_NOT_FOUND
),
detail=detail if detail else "Pipeline not found",
)
@@ -357,15 +372,19 @@ async def delete_pipeline(
async def get_pipelines(
request: Request, urlIdx: Optional[int] = None, user=Depends(get_admin_user)
):
r = None
response = None
try:
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
r = requests.get(f"{url}/pipelines", headers={"Authorization": f"Bearer {key}"})
r.raise_for_status()
data = r.json()
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
f"{url}/pipelines",
headers={"Authorization": f"Bearer {key}"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
return {**data}
except Exception as e:
@@ -373,16 +392,18 @@ async def get_pipelines(
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
if response is not None:
try:
res = r.json()
res = await response.json()
if "detail" in res:
detail = res["detail"]
except Exception:
pass
raise HTTPException(
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
status_code=(
response.status if response is not None else status.HTTP_404_NOT_FOUND
),
detail=detail if detail else "Pipeline not found",
)
@@ -394,17 +415,19 @@ async def get_pipeline_valves(
pipeline_id: str,
user=Depends(get_admin_user),
):
r = None
response = None
try:
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
r = requests.get(
f"{url}/{pipeline_id}/valves", headers={"Authorization": f"Bearer {key}"}
)
r.raise_for_status()
data = r.json()
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
f"{url}/{pipeline_id}/valves",
headers={"Authorization": f"Bearer {key}"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
return {**data}
except Exception as e:
@@ -412,16 +435,18 @@ async def get_pipeline_valves(
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
if response is not None:
try:
res = r.json()
res = await response.json()
if "detail" in res:
detail = res["detail"]
except Exception:
pass
raise HTTPException(
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
status_code=(
response.status if response is not None else status.HTTP_404_NOT_FOUND
),
detail=detail if detail else "Pipeline not found",
)
@@ -433,18 +458,19 @@ async def get_pipeline_valves_spec(
pipeline_id: str,
user=Depends(get_admin_user),
):
r = None
response = None
try:
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
r = requests.get(
f"{url}/{pipeline_id}/valves/spec",
headers={"Authorization": f"Bearer {key}"},
)
r.raise_for_status()
data = r.json()
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
f"{url}/{pipeline_id}/valves/spec",
headers={"Authorization": f"Bearer {key}"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
return {**data}
except Exception as e:
@@ -452,16 +478,18 @@ async def get_pipeline_valves_spec(
log.exception(f"Connection error: {e}")
detail = None
if r is not None:
if response is not None:
try:
res = r.json()
res = await response.json()
if "detail" in res:
detail = res["detail"]
except Exception:
pass
raise HTTPException(
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
status_code=(
response.status if response is not None else status.HTTP_404_NOT_FOUND
),
detail=detail if detail else "Pipeline not found",
)
@@ -474,19 +502,20 @@ async def update_pipeline_valves(
form_data: dict,
user=Depends(get_admin_user),
):
r = None
response = None
try:
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
r = requests.post(
f"{url}/{pipeline_id}/valves/update",
headers={"Authorization": f"Bearer {key}"},
json={**form_data},
)
r.raise_for_status()
data = r.json()
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
f"{url}/{pipeline_id}/valves/update",
headers={"Authorization": f"Bearer {key}"},
json={**form_data},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
return {**data}
except Exception as e:
@@ -495,15 +524,17 @@ async def update_pipeline_valves(
detail = None
if r is not None:
if response is not None:
try:
res = r.json()
res = await response.json()
if "detail" in res:
detail = res["detail"]
except Exception:
pass
raise HTTPException(
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
status_code=(
response.status if response is not None else status.HTTP_404_NOT_FOUND
),
detail=detail if detail else "Pipeline not found",
)
+555 -43
View File
@@ -5,18 +5,41 @@ from open_webui.models.prompts import (
PromptForm,
PromptUserResponse,
PromptAccessResponse,
PromptAccessListResponse,
PromptModel,
Prompts,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
PromptHistories,
PromptHistoryModel,
PromptHistoryResponse,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from pydantic import BaseModel
class PromptVersionUpdateForm(BaseModel):
version_id: str
class PromptMetadataForm(BaseModel):
name: str
command: str
tags: Optional[list[str]] = None
router = APIRouter()
PAGE_ITEM_COUNT = 30
############################
# GetPrompts
############################
@@ -34,26 +57,88 @@ async def get_prompts(
return prompts
@router.get("/list", response_model=list[PromptAccessResponse])
async def get_prompt_list(
@router.get("/tags", response_model=list[str])
async def get_prompt_tags(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts(db=db)
return Prompts.get_tags(db=db)
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
tags = set()
for prompt in prompts:
if prompt.tags:
tags.update(prompt.tags)
return sorted(list(tags))
return [
PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
for prompt in prompts
]
@router.get("/list", response_model=PromptAccessListResponse)
async def get_prompt_list(
query: Optional[str] = None,
view_option: Optional[str] = None,
tag: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if tag:
filter["tag"] = tag
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
# Pre-fetch user group IDs once - used for both filter and write_access check
groups = Groups.get_groups_by_member_id(user.id, db=db)
user_group_ids = {group.id for group in groups}
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Prompts.search_prompts(
user.id, filter=filter, skip=skip, limit=limit, db=db
)
# Batch-fetch writable prompt IDs in a single query instead of N has_access calls
prompt_ids = [prompt.id for prompt in result.items]
writable_prompt_ids = AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type="prompt",
resource_ids=prompt_ids,
permission="write",
user_group_ids=user_group_ids,
db=db,
)
return PromptAccessListResponse(
items=[
PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or prompt.id in writable_prompt_ids
),
)
for prompt in result.items
],
total=result.total,
)
############################
@@ -112,52 +197,115 @@ async def create_new_prompt(
async def get_prompt_by_command(
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
prompt = Prompts.get_prompt_by_command(command, db=db)
if prompt:
if (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
return PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# UpdatePromptByCommand
# GetPromptById
############################
@router.post("/command/{command}/update", response_model=Optional[PromptModel])
async def update_prompt_by_command(
command: str,
@router.get("/id/{prompt_id}", response_model=Optional[PromptAccessResponse])
async def get_prompt_by_id(
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if prompt:
if (
user.role == "admin"
or prompt.user_id == user.id
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
return PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
),
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# UpdatePromptById
############################
@router.post("/id/{prompt_id}/update", response_model=Optional[PromptModel])
async def update_prompt_by_id(
prompt_id: str,
form_data: PromptForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Is the user the original creator, in a group with write access, or an admin
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -165,35 +313,56 @@ async def update_prompt_by_command(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
prompt = Prompts.update_prompt_by_command(f"/{command}", form_data, db=db)
if prompt:
return prompt
# Check for command collision if command is being changed
if form_data.command != prompt.command:
existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
if existing_prompt and existing_prompt.id != prompt.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Command '/{form_data.command}' is already in use by another prompt",
)
# Use the ID from the found prompt
updated_prompt = Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db)
if updated_prompt:
return updated_prompt
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
############################
# DeletePromptByCommand
# UpdatePromptMetadata
############################
@router.delete("/command/{command}/delete", response_model=bool)
async def delete_prompt_by_command(
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
@router.post("/id/{prompt_id}/update/meta", response_model=Optional[PromptModel])
async def update_prompt_metadata(
prompt_id: str,
form_data: PromptMetadataForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
"""Update prompt name and command only (no history created)."""
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -201,5 +370,348 @@ async def delete_prompt_by_command(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Prompts.delete_prompt_by_command(f"/{command}", db=db)
# Check for command collision if command is being changed
if form_data.command != prompt.command:
existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
if existing_prompt and existing_prompt.id != prompt.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Command '/{form_data.command}' is already in use",
)
updated_prompt = Prompts.update_prompt_metadata(
prompt.id, form_data.name, form_data.command, form_data.tags, db=db
)
if updated_prompt:
return updated_prompt
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
@router.post("/id/{prompt_id}/update/version", response_model=Optional[PromptModel])
async def set_prompt_version(
prompt_id: str,
form_data: PromptVersionUpdateForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
prompt.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
updated_prompt = Prompts.update_prompt_version(
prompt.id, form_data.version_id, db=db
)
if updated_prompt:
return updated_prompt
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
############################
# UpdatePromptAccessById
############################
class PromptAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post("/id/{prompt_id}/access/update", response_model=Optional[PromptModel])
async def update_prompt_access_by_id(
request: Request,
prompt_id: str,
form_data: PromptAccessGrantsForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
prompt.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_prompts",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db)
return Prompts.get_prompt_by_id(prompt_id, db=db)
############################
# DeletePromptById
############################
@router.delete("/id/{prompt_id}/delete", response_model=bool)
async def delete_prompt_by_id(
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
prompt.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Prompts.delete_prompt_by_id(prompt.id, db=db)
return result
############################
# Prompt History Endpoints
############################
@router.get("/id/{prompt_id}/history", response_model=list[PromptHistoryResponse])
async def get_prompt_history(
prompt_id: str,
page: int = 0,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Get version history for a prompt."""
PAGE_SIZE = 20
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check read access
if not (
user.role == "admin"
or prompt.user_id == user.id
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
history = PromptHistories.get_history_by_prompt_id(
prompt.id, limit=PAGE_SIZE, offset=page * PAGE_SIZE, db=db
)
return history
@router.get("/id/{prompt_id}/history/{history_id}", response_model=PromptHistoryModel)
async def get_prompt_history_entry(
prompt_id: str,
history_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Get a specific version from history."""
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check read access
if not (
user.role == "admin"
or prompt.user_id == user.id
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
history_entry = PromptHistories.get_history_entry_by_id(history_id, db=db)
if not history_entry or history_entry.prompt_id != prompt.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
return history_entry
@router.delete("/id/{prompt_id}/history/{history_id}", response_model=bool)
async def delete_prompt_history_entry(
prompt_id: str,
history_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Delete a history entry. Cannot delete the active production version."""
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check write access
if not (
user.role == "admin"
or prompt.user_id == user.id
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Cannot delete active production version
if prompt.version_id == history_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete the active production version",
)
success = PromptHistories.delete_history_entry(history_id, db=db)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
return success
@router.get("/id/{prompt_id}/history/diff")
async def get_prompt_diff(
prompt_id: str,
from_id: str,
to_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Get diff between two versions."""
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check read access
if not (
user.role == "admin"
or prompt.user_id == user.id
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
diff = PromptHistories.compute_diff(from_id, to_id, db=db)
if not diff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="One or both history entries not found",
)
return diff
+126 -40
View File
@@ -39,7 +39,7 @@ from langchain_core.documents import Document
from open_webui.models.files import FileModel, FileUpdateForm, Files
from open_webui.models.knowledge import Knowledges
from open_webui.storage.provider import Storage
from open_webui.internal.db import get_session
from open_webui.internal.db import get_session, get_db
from sqlalchemy.orm import Session
@@ -76,6 +76,7 @@ from open_webui.retrieval.web.perplexity import search_perplexity
from open_webui.retrieval.web.sougou import search_sougou
from open_webui.retrieval.web.firecrawl import search_firecrawl
from open_webui.retrieval.web.external import search_external
from open_webui.retrieval.web.yandex import search_yandex
from open_webui.retrieval.utils import (
get_content_from_url,
@@ -109,6 +110,7 @@ from open_webui.config import (
from open_webui.env import (
DEVICE_TYPE,
DOCKER,
RAG_EMBEDDING_TIMEOUT,
SENTENCE_TRANSFORMERS_BACKEND,
SENTENCE_TRANSFORMERS_MODEL_KWARGS,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
@@ -468,6 +470,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
# Content extraction settings
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
@@ -577,6 +580,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
},
}
@@ -640,6 +646,9 @@ class WebConfig(BaseModel):
YOUTUBE_LOADER_LANGUAGE: Optional[List[str]] = None
YOUTUBE_LOADER_PROXY_URL: Optional[str] = None
YOUTUBE_LOADER_TRANSLATION: Optional[str] = None
YANDEX_WEB_SEARCH_URL: Optional[str] = None
YANDEX_WEB_SEARCH_API_KEY: Optional[str] = None
YANDEX_WEB_SEARCH_CONFIG: Optional[str] = None
class ConfigForm(BaseModel):
@@ -659,6 +668,7 @@ class ConfigForm(BaseModel):
# Content extraction settings
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
PDF_EXTRACT_IMAGES: Optional[bool] = None
PDF_LOADER_MODE: Optional[str] = None
DATALAB_MARKER_API_KEY: Optional[str] = None
DATALAB_MARKER_API_BASE_URL: Optional[str] = None
@@ -786,6 +796,11 @@ async def update_rag_config(
if form_data.PDF_EXTRACT_IMAGES is not None
else request.app.state.config.PDF_EXTRACT_IMAGES
)
request.app.state.config.PDF_LOADER_MODE = (
form_data.PDF_LOADER_MODE
if form_data.PDF_LOADER_MODE is not None
else request.app.state.config.PDF_LOADER_MODE
)
request.app.state.config.DATALAB_MARKER_API_KEY = (
form_data.DATALAB_MARKER_API_KEY
if form_data.DATALAB_MARKER_API_KEY is not None
@@ -1006,6 +1021,11 @@ async def update_rag_config(
if form_data.TEXT_SPLITTER is not None
else request.app.state.config.TEXT_SPLITTER
)
request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = (
form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
if form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER is not None
else request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
)
request.app.state.config.CHUNK_SIZE = (
form_data.CHUNK_SIZE
if form_data.CHUNK_SIZE is not None
@@ -1023,13 +1043,25 @@ async def update_rag_config(
)
# File upload settings
request.app.state.config.FILE_MAX_SIZE = form_data.FILE_MAX_SIZE
request.app.state.config.FILE_MAX_COUNT = form_data.FILE_MAX_COUNT
request.app.state.config.FILE_MAX_SIZE = (
form_data.FILE_MAX_SIZE
if form_data.FILE_MAX_SIZE is not None
else request.app.state.config.FILE_MAX_SIZE
)
request.app.state.config.FILE_MAX_COUNT = (
form_data.FILE_MAX_COUNT
if form_data.FILE_MAX_COUNT is not None
else request.app.state.config.FILE_MAX_COUNT
)
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
form_data.FILE_IMAGE_COMPRESSION_WIDTH
if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None
else request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH
)
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
form_data.FILE_IMAGE_COMPRESSION_HEIGHT
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None
else request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT
)
request.app.state.config.ALLOWED_FILE_EXTENSIONS = (
form_data.ALLOWED_FILE_EXTENSIONS
@@ -1164,6 +1196,15 @@ async def update_rag_config(
request.app.state.YOUTUBE_LOADER_TRANSLATION = (
form_data.web.YOUTUBE_LOADER_TRANSLATION
)
request.app.state.config.YANDEX_WEB_SEARCH_URL = (
form_data.web.YANDEX_WEB_SEARCH_URL
)
request.app.state.config.YANDEX_WEB_SEARCH_API_KEY = (
form_data.web.YANDEX_WEB_SEARCH_API_KEY
)
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = (
form_data.web.YANDEX_WEB_SEARCH_CONFIG
)
return {
"status": True,
@@ -1180,6 +1221,7 @@ async def update_rag_config(
# Content extraction settings
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
@@ -1287,6 +1329,9 @@ async def update_rag_config(
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
},
}
@@ -1417,8 +1462,16 @@ def save_docs_to_vector_db(
if result is not None and result.ids and len(result.ids) > 0:
existing_doc_ids = result.ids[0]
if existing_doc_ids:
log.info(f"Document with hash {metadata['hash']} already exists")
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
# Check if the existing document belongs to the same file
# If same file_id, this is a re-add/reindex - allow it
# If different file_id, this is a duplicate - block it
existing_file_id = None
if result.metadatas and result.metadatas[0]:
existing_file_id = result.metadatas[0][0].get("file_id")
if existing_file_id != metadata.get("file_id"):
log.info(f"Document with hash {metadata['hash']} already exists")
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
if split:
if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER:
@@ -1538,14 +1591,19 @@ def save_docs_to_vector_db(
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
)
# Run async embedding in sync context
embeddings = asyncio.run(
# Run async embedding in sync context using the main event loop
# This allows the main loop to stay responsive to health checks during long operations
embedding_timeout = RAG_EMBEDDING_TIMEOUT
future = asyncio.run_coroutine_threadsafe(
embedding_function(
list(map(lambda x: x.replace("\n", " "), texts)),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user,
)
),
request.app.state.main_loop,
)
embeddings = future.result(timeout=embedding_timeout)
log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items")
items = [
@@ -1586,6 +1644,9 @@ def process_file(
):
"""
Process a file and save its content to the vector database.
Process a file and save its content to the vector database.
Note: granular session management is used to prevent connection pool exhaustion.
The session is committed before external API calls, and updates use a fresh session.
"""
if user.role == "admin":
file = Files.get_file_by_id(form_data.file_id, db=db)
@@ -1747,6 +1808,12 @@ def process_file(
}
else:
try:
# Commit any pending changes before the slow embedding step.
# Note: file is already a Pydantic model (not ORM), so no expunge needed.
db.commit()
# External embedding API takes time (5-60s+).
# Subsequent updates use fresh sessions via get_db().
result = save_docs_to_vector_db(
request,
docs=docs,
@@ -1762,27 +1829,29 @@ def process_file(
log.info(f"added {len(docs)} items to collection {collection_name}")
if result:
Files.update_file_metadata_by_id(
file.id,
{
# Fresh session for the final update.
with get_db() as session:
Files.update_file_metadata_by_id(
file.id,
{
"collection_name": collection_name,
},
db=session,
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
db=session,
)
Files.update_file_hash_by_id(file.id, hash, db=session)
return {
"status": True,
"collection_name": collection_name,
},
db=db,
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
db=db,
)
Files.update_file_hash_by_id(file.id, hash, db=db)
return {
"status": True,
"collection_name": collection_name,
"filename": file.filename,
"content": text_content,
}
"filename": file.filename,
"content": text_content,
}
else:
raise Exception("Error saving document to vector database")
except Exception as e:
@@ -1790,13 +1859,15 @@ def process_file(
except Exception as e:
log.exception(e)
Files.update_file_data_by_id(
file.id,
{"status": "failed"},
db=db,
)
# Clear the hash so the file can be re-uploaded after fixing the issue
Files.update_file_hash_by_id(file.id, None, db=db)
# Fresh session for error status update.
with get_db() as session:
Files.update_file_data_by_id(
file.id,
{"status": "failed"},
db=session,
)
# Clear the hash so the file can be re-uploaded after fixing the issue
Files.update_file_hash_by_id(file.id, None, db=session)
if "No pandoc was found" in str(e):
raise HTTPException(
@@ -2206,6 +2277,17 @@ def search_web(
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
user=user,
)
elif engine == "yandex":
return search_yandex(
request,
request.app.state.config.YANDEX_WEB_SEARCH_URL,
request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
user=user,
)
else:
raise Exception("No search engine API key found in environment variables")
@@ -2626,10 +2708,14 @@ async def process_files_batch(
request: Request,
form_data: BatchProcessFilesForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
) -> BatchProcessFilesResponse:
"""
Process a batch of files and save them to the vector database.
NOTE: We intentionally do NOT use Depends(get_session) here.
The save_docs_to_vector_db() call makes external embedding API calls which
can take 5-60+ seconds for batch operations. Database operations after
embedding (Files.update_file_by_id) manage their own short-lived sessions.
"""
collection_name = form_data.collection_name
@@ -2689,9 +2775,7 @@ async def process_files_batch(
# Update all files with collection name
for file_update, file_result in zip(file_updates, file_results):
Files.update_file_by_id(
id=file_result.file_id, form_data=file_update, db=db
)
Files.update_file_by_id(id=file_result.file_id, form_data=file_update)
file_result.status = "completed"
except Exception as e:
@@ -2701,7 +2785,9 @@ async def process_files_batch(
for file_result in file_results:
file_result.status = "failed"
file_errors.append(
BatchProcessFilesResult(file_id=file_result.file_id, error=str(e))
BatchProcessFilesResult(
file_id=file_result.file_id, status="failed", error=str(e)
)
)
return BatchProcessFilesResponse(results=file_results, errors=file_errors)
+92 -19
View File
@@ -25,6 +25,9 @@ from open_webui.utils.auth import (
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import OAUTH_PROVIDERS
from open_webui.env import SCIM_AUTH_PROVIDER
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
@@ -300,6 +303,43 @@ def get_scim_auth(
)
def get_external_id(user: UserModel) -> Optional[str]:
"""Extract externalId from a user's scim data.
Checks all stored provider entries and returns the first external_id found.
"""
if not user.scim:
return None
for provider_data in user.scim.values():
if isinstance(provider_data, dict) and "external_id" in provider_data:
return provider_data["external_id"]
return None
def get_scim_provider() -> str:
"""Return the configured SCIM auth provider.
Requires SCIM_AUTH_PROVIDER env var to be set (e.g. 'microsoft', 'oidc').
"""
if not SCIM_AUTH_PROVIDER:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="SCIM_AUTH_PROVIDER environment variable is required when SCIM is enabled",
)
return SCIM_AUTH_PROVIDER
def find_user_by_external_id(external_id: str, db=None) -> Optional[UserModel]:
"""Find a user by SCIM externalId, falling back to OAuth sub match."""
provider = get_scim_provider()
user = Users.get_user_by_scim_external_id(provider, external_id, db=db)
if user:
return user
# Fallback: check if externalId matches an existing OAuth sub (account linking)
return Users.get_user_by_oauth_sub(provider, external_id, db=db)
def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
"""Convert internal User model to SCIM User"""
# Parse display name into name components
@@ -321,6 +361,7 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
return SCIMUser(
id=user.id,
externalId=get_external_id(user),
userName=user.email,
name=SCIMName(
formatted=user.name,
@@ -352,18 +393,17 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup:
"""Convert internal Group model to SCIM Group"""
member_ids = Groups.get_group_user_ids_by_id(group.id, db) or []
members = []
for user_id in member_ids:
user = Users.get_user_by_id(user_id, db=db)
if user:
members.append(
SCIMGroupMember(
value=user.id,
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
display=user.name,
)
)
# Batch-fetch all users to avoid N+1 queries
users = Users.get_users_by_user_ids(member_ids, db=db) if member_ids else []
members = [
SCIMGroupMember(
value=user.id,
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
display=user.name,
)
for user in users
]
return SCIMGroup(
id=group.id,
@@ -495,13 +535,17 @@ async def get_users(
# Get users from database
if filter:
# Simple filter parsing - supports userName eq "email"
# In production, you'd want a more robust filter parser
# Simple filter parsing - supports userName eq, externalId eq
if "userName eq" in filter:
email = filter.split('"')[1]
user = Users.get_user_by_email(email, db=db)
users_list = [user] if user else []
total = 1 if user else 0
elif "externalId eq" in filter:
external_id = filter.split('"')[1]
user = find_user_by_external_id(external_id, db=db)
users_list = [user] if user else []
total = 1 if user else 0
else:
response = Users.get_users(skip=skip, limit=limit, db=db)
users_list = response["users"]
@@ -547,17 +591,33 @@ async def create_user(
db: Session = Depends(get_session),
):
"""Create SCIM User"""
# Check if user already exists
existing_user = Users.get_user_by_email(user_data.userName, db=db)
# Check for duplicate by externalId
if user_data.externalId:
existing_user = find_user_by_external_id(user_data.externalId, db=db)
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with externalId {user_data.externalId} already exists",
)
# Determine primary email (lowercased per RFC 5321)
email = user_data.userName
for entry in user_data.emails:
if entry.primary:
email = entry.value
break
email = email.lower()
# Check for duplicate by email
existing_user = Users.get_user_by_email(email, db=db)
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with email {user_data.userName} already exists",
detail=f"User with email {email} already exists",
)
# Create user
user_id = str(uuid.uuid4())
email = user_data.emails[0].value if user_data.emails else user_data.userName
# Parse name if provided
name = user_data.displayName
@@ -572,7 +632,6 @@ async def create_user(
if user_data.photos and len(user_data.photos) > 0:
profile_image = user_data.photos[0].value
# Create user
new_user = Users.insert_new_user(
id=user_id,
name=name,
@@ -588,6 +647,12 @@ async def create_user(
detail="Failed to create user",
)
# Store externalId in the scim field
if user_data.externalId:
provider = get_scim_provider()
Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db)
new_user = Users.get_user_by_id(user_id, db=db)
return user_to_scim(new_user, request, db=db)
@@ -632,7 +697,6 @@ async def update_user(
if user_data.photos and len(user_data.photos) > 0:
update_data["profile_image_url"] = user_data.photos[0].value
# Update user
updated_user = Users.update_user_by_id(user_id, update_data, db=db)
if not updated_user:
raise HTTPException(
@@ -640,6 +704,12 @@ async def update_user(
detail="Failed to update user",
)
# Update externalId in the scim field
if user_data.externalId:
provider = get_scim_provider()
Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db)
updated_user = Users.get_user_by_id(user_id, db=db)
return user_to_scim(updated_user, request, db=db)
@@ -677,6 +747,9 @@ async def patch_user(
update_data["email"] = value
elif path == "name.formatted":
update_data["name"] = value
elif path == "externalId":
provider = get_scim_provider()
Users.update_user_scim_by_id(user_id, provider, value, db=db)
# Update user
if update_data:
+447
View File
@@ -0,0 +1,447 @@
import logging
from typing import Optional
from open_webui.models.groups import Groups
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.skills import (
SkillForm,
SkillModel,
SkillResponse,
SkillUserResponse,
SkillAccessResponse,
SkillAccessListResponse,
Skills,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
PAGE_ITEM_COUNT = 30
router = APIRouter()
############################
# GetSkills
############################
@router.get("/", response_model=list[SkillUserResponse])
async def get_skills(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
skills = Skills.get_skills(db=db)
else:
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
all_skills = Skills.get_skills(db=db)
skills = [
skill
for skill in all_skills
if skill.user_id == user.id
or AccessGrants.has_access(
user_id=user.id,
resource_type="skill",
resource_id=skill.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
)
]
return skills
############################
# GetSkillList
############################
@router.get("/list", response_model=SkillAccessListResponse)
async def get_skill_list(
query: Optional[str] = None,
view_option: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db)
return SkillAccessListResponse(
items=[
SkillAccessResponse(
**skill.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == skill.user_id
or AccessGrants.has_access(
user_id=user.id,
resource_type="skill",
resource_id=skill.id,
permission="write",
db=db,
)
),
)
for skill in result.items
],
total=result.total,
)
############################
# ExportSkills
############################
@router.get("/export", response_model=list[SkillModel])
async def export_skills(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id,
"workspace.skills",
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return Skills.get_skills(db=db)
else:
return Skills.get_skills_by_user_id(user.id, "read", db=db)
############################
# CreateNewSkill
############################
@router.post("/create", response_model=Optional[SkillResponse])
async def create_new_skill(
request: Request,
form_data: SkillForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "workspace.skills", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.id = form_data.id.lower().replace(" ", "-")
existing = Skills.get_skill_by_id(form_data.id, db=db)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ID_TAKEN,
)
try:
skill = Skills.insert_new_skill(user.id, form_data, db=db)
if skill:
return skill
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error creating skill"),
)
except Exception as e:
log.exception(f"Failed to create skill: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
)
############################
# GetSkillById
############################
@router.get("/id/{id}", response_model=Optional[SkillAccessResponse])
async def get_skill_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
skill = Skills.get_skill_by_id(id, db=db)
if skill:
if (
user.role == "admin"
or skill.user_id == user.id
or AccessGrants.has_access(
user_id=user.id,
resource_type="skill",
resource_id=skill.id,
permission="read",
db=db,
)
):
return SkillAccessResponse(
**skill.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == skill.user_id
or AccessGrants.has_access(
user_id=user.id,
resource_type="skill",
resource_id=skill.id,
permission="write",
db=db,
)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# UpdateSkillById
############################
@router.post("/id/{id}/update", response_model=Optional[SkillModel])
async def update_skill_by_id(
request: Request,
id: str,
form_data: SkillForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
skill = Skills.get_skill_by_id(id, db=db)
if not skill:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
skill.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="skill",
resource_id=skill.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
try:
updated = {
**form_data.model_dump(exclude={"id"}),
}
skill = Skills.update_skill_by_id(id, updated, db=db)
if skill:
return skill
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating skill"),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
)
############################
# UpdateSkillAccessById
############################
class SkillAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post("/id/{id}/access/update", response_model=Optional[SkillModel])
async def update_skill_access_by_id(
request: Request,
id: str,
form_data: SkillAccessGrantsForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
skill = Skills.get_skill_by_id(id, db=db)
if not skill:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
skill.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="skill",
resource_id=skill.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_skills",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db)
return Skills.get_skill_by_id(id, db=db)
############################
# ToggleSkillById
############################
@router.post("/id/{id}/toggle", response_model=Optional[SkillModel])
async def toggle_skill_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
skill = Skills.get_skill_by_id(id, db=db)
if skill:
if (
user.role == "admin"
or skill.user_id == user.id
or AccessGrants.has_access(
user_id=user.id,
resource_type="skill",
resource_id=skill.id,
permission="write",
db=db,
)
):
skill = Skills.toggle_skill_by_id(id, db=db)
if skill:
return skill
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error toggling skill"),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# DeleteSkillById
############################
@router.delete("/id/{id}/delete", response_model=bool)
async def delete_skill_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
skill = Skills.get_skill_by_id(id, db=db)
if not skill:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
skill.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="skill",
resource_id=skill.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
result = Skills.delete_skill_by_id(id, db=db)
return result
+15 -1
View File
@@ -36,7 +36,6 @@ from open_webui.config import (
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
)
log = logging.getLogger(__name__)
router = APIRouter()
@@ -49,6 +48,21 @@ router = APIRouter()
##################################
class ActiveChatsForm(BaseModel):
chat_ids: list[str]
@router.post("/active/chats")
async def check_active_chats(
request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)
):
"""Check which chat IDs have active tasks."""
from open_webui.tasks import get_active_chat_ids
active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids)
return {"active_chat_ids": active}
@router.get("/config")
async def get_task_config(request: Request, user=Depends(get_verified_user)):
return {
+155 -23
View File
@@ -21,10 +21,12 @@ from open_webui.models.tools import (
ToolAccessResponse,
Tools,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.utils.plugin import (
load_tool_module_by_id,
replace_imports,
get_tool_module_from_cache,
resolve_valves_schema_options,
)
from open_webui.utils.tools import get_tool_specs
from open_webui.utils.auth import get_admin_user, get_verified_user
@@ -34,7 +36,6 @@ from open_webui.utils.tools import get_tool_servers
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
@@ -75,12 +76,21 @@ async def get_tools(
)
# OpenAPI Tool Servers
server_access_grants = {}
for server in await get_tool_servers(request):
connection = request.app.state.config.TOOL_SERVER_CONNECTIONS[
server.get("idx", 0)
]
server_config = connection.get("config", {})
server_id = f"server:{server.get('id')}"
server_access_grants[server_id] = server_config.get("access_grants", [])
tools.append(
ToolUserResponse(
**{
"id": f"server:{server.get('id')}",
"user_id": f"server:{server.get('id')}",
"id": server_id,
"user_id": server_id,
"name": server.get("openapi", {})
.get("info", {})
.get("title", "Tool Server"),
@@ -89,11 +99,6 @@ async def get_tools(
.get("info", {})
.get("description", ""),
},
"access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[
server.get("idx", 0)
]
.get("config", {})
.get("access_control", None),
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
@@ -102,7 +107,9 @@ async def get_tools(
# MCP Tool Servers
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if server.get("type", "openapi") == "mcp":
if server.get("type", "openapi") == "mcp" and server.get("config", {}).get(
"enable"
):
server_id = server.get("info", {}).get("id")
auth_type = server.get("auth_type", "none")
@@ -117,20 +124,22 @@ async def get_tools(
)
)
server_config = server.get("config", {})
tool_id = f"server:mcp:{server.get('info', {}).get('id')}"
server_access_grants[tool_id] = server_config.get("access_grants", [])
tools.append(
ToolUserResponse(
**{
"id": f"server:mcp:{server.get('info', {}).get('id')}",
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
"id": tool_id,
"user_id": tool_id,
"name": server.get("info", {}).get("name", "MCP Tool Server"),
"meta": {
"description": server.get("info", {}).get(
"description", ""
),
},
"access_control": server.get("config", {}).get(
"access_control", None
),
"updated_at": int(time.time()),
"created_at": int(time.time()),
**(
@@ -155,7 +164,24 @@ async def get_tools(
tool
for tool in tools
if tool.user_id == user.id
or has_access(user.id, "read", tool.access_control, user_group_ids, db=db)
or (
has_access(
user.id,
"read",
server_access_grants.get(str(tool.id), []),
user_group_ids,
db=db,
)
if str(tool.id).startswith("server:")
else AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tool.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
)
)
]
return tools
@@ -180,7 +206,13 @@ async def get_tool_list(
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tool.user_id
or has_access(user.id, "write", tool.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tool.id,
permission="write",
db=db,
)
),
)
for tool in tools
@@ -381,14 +413,26 @@ async def get_tools_by_id(
if (
user.role == "admin"
or tools.user_id == user.id
or has_access(user.id, "read", tools.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="read",
db=db,
)
):
return ToolAccessResponse(
**tools.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tools.user_id
or has_access(user.id, "write", tools.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
),
)
else:
@@ -426,7 +470,13 @@ async def update_tools_by_id(
# Is the user the original creator, in a group with write access, or an admin
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -467,6 +517,70 @@ async def update_tools_by_id(
)
############################
# UpdateToolAccessById
############################
class ToolAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post("/id/{id}/access/update", response_model=Optional[ToolModel])
async def update_tool_access_by_id(
request: Request,
id: str,
form_data: ToolAccessGrantsForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id, db=db)
if not tools:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
tools.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_tools",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db)
return Tools.get_tool_by_id(id, db=db)
############################
# DeleteToolsById
############################
@@ -488,7 +602,13 @@ async def delete_tools_by_id(
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -553,7 +673,10 @@ async def get_tools_valves_spec_by_id(
if hasattr(tools_module, "Valves"):
Valves = tools_module.Valves
return Valves.schema()
schema = Valves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(Valves, schema, user)
return schema
return None
else:
raise HTTPException(
@@ -584,7 +707,13 @@ async def update_tools_valves_by_id(
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@@ -662,7 +791,10 @@ async def get_tools_user_valves_spec_by_id(
if hasattr(tools_module, "UserValves"):
UserValves = tools_module.UserValves
return UserValves.schema()
schema = UserValves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(UserValves, schema, user)
return schema
return None
else:
raise HTTPException(
+28 -7
View File
@@ -19,7 +19,7 @@ from open_webui.models.users import (
UserModel,
UserGroupIdsModel,
UserGroupIdsListResponse,
UserInfoListResponse,
UserInfoResponse,
UserInfoListResponse,
UserRoleUpdateForm,
UserStatus,
@@ -41,7 +41,6 @@ from open_webui.utils.auth import (
)
from open_webui.utils.access_control import get_permissions, has_permission
log = logging.getLogger(__name__)
router = APIRouter()
@@ -173,6 +172,7 @@ class WorkspacePermissions(BaseModel):
knowledge: bool = False
prompts: bool = False
tools: bool = False
skills: bool = False
models_import: bool = False
models_export: bool = False
prompts_import: bool = False
@@ -190,6 +190,8 @@ class SharingPermissions(BaseModel):
public_prompts: bool = False
tools: bool = False
public_tools: bool = True
skills: bool = False
public_skills: bool = False
notes: bool = False
public_notes: bool = True
@@ -446,7 +448,7 @@ class UserActiveResponse(UserStatus):
@router.get("/{user_id}", response_model=UserActiveResponse)
async def get_user_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
# Check if user_id is a shared chat
# If it is, get the user_id from the chat
@@ -478,6 +480,27 @@ async def get_user_by_id(
)
@router.get("/{user_id}/info", response_model=UserInfoResponse)
async def get_user_info_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user_id, db=db)
if user:
groups = Groups.get_groups_by_member_id(user_id, db=db)
return UserInfoResponse(
**{
**user.model_dump(),
"groups": [{"id": group.id, "name": group.name} for group in groups],
"is_active": Users.is_user_active(user_id, db=db),
}
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
@router.get("/{user_id}/oauth/sessions")
async def get_user_oauth_sessions_by_id(
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
@@ -498,10 +521,8 @@ async def get_user_oauth_sessions_by_id(
@router.get("/{user_id}/profile/image")
async def get_user_profile_image_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user_id, db=db)
def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)):
user = Users.get_user_by_id(user_id)
if user:
if user.profile_image_url:
# check if it's url or base64

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