diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index fa82ae26a1..0ec871f328 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -4,14 +4,15 @@
**Before submitting, make sure you've checked the following:**
-- [ ] **Target branch:** Please verify that the pull request targets the `dev` branch.
+- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. Not targeting the `dev` branch may lead to immediate closure of the PR.
- [ ] **Description:** Provide a concise description of the changes made in this pull request.
- [ ] **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:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
+- [ ] **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:** Have you written and run sufficient tests to validate the changes?
+- [ ] **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.
+- [ ] **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?
-- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
+- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e67c9fbd5..a69bb9dace 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,166 @@ 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.6.32] - 2025-09-29
+
+### Added
+
+- ⚡ JSON model import moved to backend processing for significant performance improvements when importing large model files. [#17871](https://github.com/open-webui/open-webui/pull/17871)
+- ⚠️ Visual warnings for group permissions that display when a permission is disabled in a group but remains enabled in the default user role, clarifying inheritance behavior for administrators. [#17848](https://github.com/open-webui/open-webui/pull/17848)
+- 🗄️ Milvus multi-tenancy mode using shared collections with resource ID filtering for improved scalability, mirroring the existing Qdrant implementation and configurable via ENABLE_MILVUS_MULTITENANCY_MODE environment variable. [#17837](https://github.com/open-webui/open-webui/pull/17837)
+- 🛠️ Enhanced tool result processing with improved error handling, better MCP tool result handling, and performance improvements for embedded UI components. [Commit](https://github.com/open-webui/open-webui/commit/4f06f29348b2c9d71c87d1bbe5b748a368f5101f)
+- 👥 New user groups now automatically inherit default group permissions, streamlining the admin setup process by eliminating manual permission configuration. [#17843](https://github.com/open-webui/open-webui/pull/17843)
+- 🗂️ Bulk unarchive functionality for all chats, providing a single backend endpoint to efficiently restore all archived chats at once. [#17857](https://github.com/open-webui/open-webui/pull/17857)
+- 🏷️ Browser tab title toggle setting allows users to control whether chat titles appear in the browser tab or display only "Open WebUI". [#17851](https://github.com/open-webui/open-webui/pull/17851)
+- 💬 Reply-to-message functionality in channels, allowing users to reply directly to specific messages with visual threading and context display. [Commit](https://github.com/open-webui/open-webui/commit/1a18928c94903ad1f1f0391b8ade042c3e60205b)
+- 🔧 Tool server import and export functionality, allowing direct upload of openapi.json and openapi.yaml files as an alternative to URL-based configuration. [#14446](https://github.com/open-webui/open-webui/issues/14446)
+- 🔧 User valve configuration for Functions is now available in the integration menu, providing consistent management alongside Tools. [#17784](https://github.com/open-webui/open-webui/issues/17784)
+- 🔐 Admin permission toggle for controlling public sharing of notes, configurable via USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING environment variable. [#17801](https://github.com/open-webui/open-webui/pull/17801), [Docs:#715](https://github.com/open-webui/docs/pull/715)
+- 🗄️ DISKANN index type support for Milvus vector database with configurable maximum degree and search list size parameters. [#17770](https://github.com/open-webui/open-webui/pull/17770), [Docs:Commit](https://github.com/open-webui/docs/commit/cec50ab4d4b659558ca1ccd4b5e6fc024f05fb83)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Chinese (Simplified & Traditional) and Bosnian (Latin) were enhanced and expanded.
+
+### Fixed
+
+- 🛠️ MCP tool calls are now correctly routed to the appropriate server when multiple streamable-http MCP servers are enabled, preventing "Tool not found" errors. [#17817](https://github.com/open-webui/open-webui/issues/17817)
+- 🛠️ External tool servers (OpenAPI/MCP) now properly process and return tool results to the model, restoring functionality that was broken in v0.6.31. [#17764](https://github.com/open-webui/open-webui/issues/17764)
+- 🔧 User valve detection now correctly identifies valves in imported tool code, ensuring gear icons appear in the integrations menu for all tools with user valves. [#17765](https://github.com/open-webui/open-webui/issues/17765)
+- 🔐 MCP OAuth discovery now correctly handles multi-tenant configurations by including subpaths in metadata URL discovery. [#17768](https://github.com/open-webui/open-webui/issues/17768)
+- 🗄️ Milvus query operations now correctly use -1 instead of None for unlimited queries, preventing TypeError exceptions. [#17769](https://github.com/open-webui/open-webui/pull/17769), [#17088](https://github.com/open-webui/open-webui/issues/17088)
+- 📁 File upload error messages are now displayed when files are modified during upload, preventing user confusion on Android and Windows devices. [#17777](https://github.com/open-webui/open-webui/pull/17777)
+- 🎨 MessageInput Integrations button hover effect now displays correctly with proper visual feedback. [#17767](https://github.com/open-webui/open-webui/pull/17767)
+- 🎯 "Set as default" label positioning is fixed to ensure it remains clickable in all scenarios, including multi-model configurations. [#17779](https://github.com/open-webui/open-webui/pull/17779)
+- 🎛️ Floating buttons now correctly retrieve message context by using the proper messageId parameter in createMessagesList calls. [#17823](https://github.com/open-webui/open-webui/pull/17823)
+- 📌 Pinned chats are now properly cleared from the sidebar after archiving all chats, ensuring UI consistency without requiring a page refresh. [#17832](https://github.com/open-webui/open-webui/pull/17832)
+- 🗑️ Delete confirmation modals now properly truncate long names for Notes, Prompts, Tools, and Functions to prevent modal overflow. [#17812](https://github.com/open-webui/open-webui/pull/17812)
+- 🌐 Internationalization function calls now use proper Svelte store subscription syntax, preventing "i18n.t is not a function" errors on the model creation page. [#17819](https://github.com/open-webui/open-webui/pull/17819)
+- 🎨 Playground chat interface button layout is corrected to prevent vertical text rendering for Assistant/User role buttons. [#17819](https://github.com/open-webui/open-webui/pull/17819)
+- 🏷️ UI text truncation is improved across multiple components including usernames in admin panels, arena model names, model tags, and filter tags to prevent layout overflow issues. [#17805](https://github.com/open-webui/open-webui/pull/17805), [#17803](https://github.com/open-webui/open-webui/pull/17803), [#17791](https://github.com/open-webui/open-webui/pull/17791), [#17796](https://github.com/open-webui/open-webui/pull/17796)
+
+## [0.6.31] - 2025-09-25
+
+### Added
+
+- 🔌 MCP (streamable HTTP) server support was added alongside existing OpenAPI server integration, allowing users to connect both server types through an improved server configuration interface. [#15932](https://github.com/open-webui/open-webui/issues/15932) [#16651](https://github.com/open-webui/open-webui/pull/16651), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406), [Commit](https://github.com/open-webui/open-webui/commit/777e81f7a8aca957a359d51df8388e5af4721a68), [Commit](https://github.com/open-webui/open-webui/commit/de7f7b3d855641450f8e5aac34fbae0665e0b80e), [Commit](https://github.com/open-webui/open-webui/commit/f1bbf3a91e4713039364b790e886e59b401572d0), [Commit](https://github.com/open-webui/open-webui/commit/c55afc42559c32a6f0c8beb0f1bb18e9360ab8af), [Commit](https://github.com/open-webui/open-webui/commit/61f20acf61f4fe30c0e5b0180949f6e1a8cf6524)
+- 🔐 To enable MCP server authentication, OAuth 2.1 dynamic client registration was implemented with secure automatic client registration, encrypted session management, and seamless authentication flows. [Commit](https://github.com/open-webui/open-webui/commit/972be4eda5a394c111e849075f94099c9c0dd9aa), [Commit](https://github.com/open-webui/open-webui/commit/77e971dd9fbeee806e2864e686df5ec75e82104b), [Commit](https://github.com/open-webui/open-webui/commit/879abd7feea3692a2f157da4a458d30f27217508), [Commit](https://github.com/open-webui/open-webui/commit/422d38fd114b1ebd8a7dbb114d64e14791e67d7a), [Docs:#709](https://github.com/open-webui/docs/pull/709)
+- 🛠️ External & Built-In Tools can now support rich UI element embedding ([Docs](https://docs.openwebui.com/features/plugin/tools/development)), allowing tools to return HTML content and interactive iframes that display directly within chat conversations with configurable security settings. [Commit](https://github.com/open-webui/open-webui/commit/07c5b25bc8b63173f406feb3ba183d375fedee6a), [Commit](https://github.com/open-webui/open-webui/commit/a5d8882bba7933a2c2c31c0a1405aba507c370bb), [Commit](https://github.com/open-webui/open-webui/commit/7be5b7f50f498de97359003609fc5993a172f084), [Commit](https://github.com/open-webui/open-webui/commit/a89ffccd7e96705a4a40e845289f4fcf9c4ae596)
+- 📝 Note editor now supports drag-and-drop reordering of list items with visual drag handles, making list organization more intuitive and efficient. [Commit](https://github.com/open-webui/open-webui/commit/e4e97e727e9b4971f1c363b1280ca3a101599d88), [Commit](https://github.com/open-webui/open-webui/commit/aeb5288a3c7a6e9e0a47b807cc52f870c1b7dbe6)
+- 🔍 Search modal was enhanced with quick action buttons for starting new conversations and creating notes, with intelligent content pre-population from search queries. [Commit](https://github.com/open-webui/open-webui/commit/aa6f63a335e172fec1dc94b2056541f52c1167a6), [Commit](https://github.com/open-webui/open-webui/commit/612a52d7bb7dbe9fa0bbbc8ac0a552d2b9801146), [Commit](https://github.com/open-webui/open-webui/commit/b03529b006f3148e895b1094584e1ab129ecac5b)
+- 🛠️ Tool user valve configuration interface was added to the integrations menu, displaying clickable gear icon buttons with tooltips for tools that support user-specific settings, making personal tool configurations easily accessible. [Commit](https://github.com/open-webui/open-webui/commit/27d61307cdce97ed11a05ec13fc300249d6022cd)
+- 👥 Channel access control was enhanced to require write permissions for posting, editing, and deleting messages, while read-only users can view content but cannot contribute. [#17543](https://github.com/open-webui/open-webui/pull/17543)
+- 💬 Channel models now support image processing, allowing AI assistants to view and analyze images shared in conversation threads. [Commit](https://github.com/open-webui/open-webui/commit/9f0010e234a6f40782a66021435d3c02b9c23639)
+- 🌐 Attach Webpage button was added to the message input menu, providing a user-friendly modal interface for attaching web content and YouTube videos as an alternative to the existing URL syntax. [#17534](https://github.com/open-webui/open-webui/pull/17534)
+- 🔐 Redis session storage support was added for OAuth redirects, providing better state handling in multi-pod Kubernetes deployments and resolving CSRF mismatch errors. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373)
+- 🔍 Ollama Cloud web search integration was added as a new search engine option, providing access to web search functionality through Ollama's cloud infrastructure. [Commit](https://github.com/open-webui/open-webui/commit/e06489d92baca095b8f376fbef223298c7772579), [Commit](https://github.com/open-webui/open-webui/commit/4b6d34438bcfc45463dc7a9cb984794b32c1f0a1), [Commit](https://github.com/open-webui/open-webui/commit/05c46008da85357dc6890b846789dfaa59f4a520), [Commit](https://github.com/open-webui/open-webui/commit/fe65fe0b97ec5a8fff71592ff04a25c8e123d108), [Docs:#708](https://github.com/open-webui/docs/pull/708)
+- 🔍 Perplexity Websearch API integration was added as a new search engine option, providing access to the new websearch functionality provided by Perplexity. [#17756](https://github.com/open-webui/open-webui/issues/17756), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/7f411dd5cc1c29733216f79e99eeeed0406a2afe)
+- ☁️ OneDrive integration was improved to support separate client IDs for personal and business authentication, enabling both integrations to work simultaneously. [#17619](https://github.com/open-webui/open-webui/pull/17619), [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
+- 📝 Pending user overlay content now supports markdown formatting, enabling rich text display for custom messages similar to banner functionality. [#17681](https://github.com/open-webui/open-webui/pull/17681)
+- 🎨 Image generation model selection was centralized to enable dynamic model override in function calls, allowing pipes and tools to specify different models than the global default while maintaining backward compatibility. [#17689](https://github.com/open-webui/open-webui/pull/17689)
+- 🎨 Interface design was modernized with updated visual styling, improved spacing, and refined component layouts across modals, sidebar, settings, and navigation elements. [Commit](https://github.com/open-webui/open-webui/commit/27a91cc80a24bda0a3a188bc3120a8ab57b00881), [Commit](https://github.com/open-webui/open-webui/commit/4ad743098615f9c58daa9df392f31109aeceeb16), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406)
+- 📊 Notes query performance was optimized through database-level filtering and separated access control logic, reducing memory usage and eliminating N+1 query problems for better scalability. [#17607](https://github.com/open-webui/open-webui/pull/17607) [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/da661756fa7eec754270e6dd8c67cbf74a28a17f)
+- ⚡ Page loading performance was optimized by deferring API requests until components are actually opened, including ChangelogModal, ModelSelector, RecursiveFolder, ArchivedChatsModal, and SearchModal. [#17542](https://github.com/open-webui/open-webui/pull/17542), [#17555](https://github.com/open-webui/open-webui/pull/17555), [#17557](https://github.com/open-webui/open-webui/pull/17557), [#17541](https://github.com/open-webui/open-webui/pull/17541), [#17640](https://github.com/open-webui/open-webui/pull/17640)
+- ⚡ Bundle size was reduced by 1.58MB through optimized highlight.js language support, improving page loading speed and reducing bandwidth usage. [#17645](https://github.com/open-webui/open-webui/pull/17645)
+- ⚡ Editor collaboration functionality was refactored to reduce package size by 390KB and minimize compilation errors, improving build performance and reliability. [#17593](https://github.com/open-webui/open-webui/pull/17593)
+- ♿ Enhanced user interface accessibility through the addition of unique element IDs, improving targeting for testing, styling, and assistive technologies while providing better semantic markup for screen readers and accessibility tools. [#17746](https://github.com/open-webui/open-webui/pull/17746)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Portuguese (Brazil), Chinese (Simplified and Traditional), Korean, Irish, Spanish, Finnish, French, Kabyle, Russian, and Catalan were enhanced and improved.
+
+### Fixed
+
+- 🛡️ SVG content security was enhanced by implementing DOMPurify sanitization to prevent XSS attacks through malicious SVG elements, ensuring safe rendering of user-generated SVG content. [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/750a659a9fee7687e667d9d755e17b8a0c77d557)
+- ☁️ OneDrive attachment menu rendering issues were resolved by restructuring the submenu interface from dropdown to tabbed navigation, preventing menu items from being hidden or clipped due to overflow constraints. [#17554](https://github.com/open-webui/open-webui/issues/17554), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/90e4b49b881b644465831cc3028bb44f0f7a2196)
+- 💬 Attached conversation references now persist throughout the entire chat session, ensuring models can continue querying referenced conversations after multiple conversation turns. [#17750](https://github.com/open-webui/open-webui/issues/17750)
+- 🔍 Search modal text box focus issues after pinning or unpinning chats were resolved, allowing users to properly exit the search interface by clicking outside the text box. [#17743](https://github.com/open-webui/open-webui/issues/17743)
+- 🔍 Search function chat list is now properly updated in real-time when chats are created or deleted, eliminating stale search results and preview loading failures. [#17741](https://github.com/open-webui/open-webui/issues/17741)
+- 💬 Chat jitter and delayed code block expansion in multi-model sessions were resolved by reverting dynamic CodeEditor loading, restoring stable rendering behavior. [#17715](https://github.com/open-webui/open-webui/pull/17715), [#17684](https://github.com/open-webui/open-webui/issues/17684)
+- 📎 File upload handling was improved to properly recognize uploaded files even when no accompanying text message is provided, resolving issues where attachments were ignored in custom prompts. [#17492](https://github.com/open-webui/open-webui/issues/17492)
+- 💬 Chat conversation referencing within projects was restored by including foldered chats in the reference menu, allowing users to properly quote conversations from within their project scope. [#17530](https://github.com/open-webui/open-webui/issues/17530)
+- 🔍 RAG query generation is now skipped when all attached files are set to full context mode, preventing unnecessary retrieval operations and improving system efficiency. [#17744](https://github.com/open-webui/open-webui/pull/17744)
+- 💾 Memory leaks in file handling and HTTP connections are prevented through proper resource cleanup, ensuring stable memory usage during large file downloads and processing operations. [#17608](https://github.com/open-webui/open-webui/pull/17608)
+- 🔐 OAuth access token refresh errors are resolved by properly implementing async/await patterns, preventing "coroutine object has no attribute get" failures during token expiry. [#17585](https://github.com/open-webui/open-webui/issues/17585), [#17678](https://github.com/open-webui/open-webui/issues/17678)
+- ⚙️ Valve behavior was improved to properly handle default values and array types, ensuring only explicitly set values are persisted while maintaining consistent distinction between custom and default valve states. [#17664](https://github.com/open-webui/open-webui/pull/17664)
+- 🔍 Hybrid search functionality was enhanced to handle inconsistent parameter types and prevent failures when collection results are None, empty, or in unexpected formats. [#17617](https://github.com/open-webui/open-webui/pull/17617)
+- 📁 Empty folder deletion is now allowed regardless of chat deletion permission restrictions, resolving cases where users couldn't remove folders after deleting all contained chats. [#17683](https://github.com/open-webui/open-webui/pull/17683)
+- 📝 Rich text editor console errors were resolved by adding proper error handling when the TipTap editor view is not available or not yet mounted. [#17697](https://github.com/open-webui/open-webui/issues/17697)
+- 🗒️ Hidden models are now properly excluded from the notes section dropdown and default model selection, preventing users from accessing models they shouldn't see. [#17722](https://github.com/open-webui/open-webui/pull/17722)
+- 🖼️ AI-generated image download filenames now use a clean, translatable "Generated Image" format instead of potentially problematic response text, improving file management and compatibility. [#17721](https://github.com/open-webui/open-webui/pull/17721)
+- 🎨 Toggle switch display issues in the Integrations interface are fixed, preventing background highlighting and obscuring on hover. [#17564](https://github.com/open-webui/open-webui/issues/17564)
+
+### Changed
+
+- 👥 Channel permissions now require write access for message posting, editing, and deletion, with existing user groups defaulting to read-only access requiring manual admin migration to write permissions for full participation.
+- ☁️ OneDrive environment variable configuration was updated to use separate ONEDRIVE_CLIENT_ID_PERSONAL and ONEDRIVE_CLIENT_ID_BUSINESS variables for better client ID separation, while maintaining backward compatibility with the legacy ONEDRIVE_CLIENT_ID variable. [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
+
+## [0.6.30] - 2025-09-17
+
+### Added
+
+- 🔑 Microsoft Entra ID authentication type support was added for Azure OpenAI connections, enabling enhanced security and streamlined authentication workflows.
+
+### Fixed
+
+- ☁️ OneDrive integration was fixed after recent breakage, restoring reliable account connectivity and file access.
+
+## [0.6.29] - 2025-09-17
+
+### Added
+
+- 🎨 The chat input menu has been completely overhauled with a revolutionary new design, consolidating attachments under a unified '+' button, organizing integrations into a streamlined options menu, and introducing powerful, interactive selectors for attaching chats, notes, and knowledge base items. [Commit](https://github.com/open-webui/open-webui/commit/a68342d5a887e36695e21f8c2aec593b159654ff), [Commit](https://github.com/open-webui/open-webui/commit/96b8aaf83ff341fef432649366bc5155bac6cf20), [Commit](https://github.com/open-webui/open-webui/commit/4977e6d50f7b931372c96dd5979ca635d58aeb78), [Commit](https://github.com/open-webui/open-webui/commit/d973db829f7ec98b8f8fe7d3b2822d588e79f94e), [Commit](https://github.com/open-webui/open-webui/commit/d4c628de09654df76653ad9bce9cb3263e2f27c8), [Commit](https://github.com/open-webui/open-webui/commit/cd740f436db4ea308dbede14ef7ff56e8126f51b), [Commit](https://github.com/open-webui/open-webui/commit/5c2db102d06b5c18beb248d795682ff422e9b6d1), [Commit](https://github.com/open-webui/open-webui/commit/031cf38655a1a2973194d2eaa0fbbd17aca8ee92), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3ed0a6d11fea1a054e0bc8aa8dfbe417c7c53e51), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/eadec9e86e01bc8f9fb90dfe7a7ae4fc3bfa6420), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c03ca7270e64e3a002d321237160c0ddaf2bb129), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b53ddfbd19aa94e9cbf7210acb31c3cfafafa5fe), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c923461882fcde30ae297a95e91176c95b9b72e1)
+- 🤖 AI models can now be mentioned in channels to automatically generate responses, enabling multi-model conversations where mentioned models participate directly in threaded discussions with full context awareness. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/4fe97d8794ee18e087790caab9e5d82886006145)
+- 💬 The Channels feature now utilizes the modern rich text editor, including support for '/', '@', and '#' command suggestions. [Commit](https://github.com/open-webui/open-webui/commit/06c1426e14ac0dfaf723485dbbc9723a4d89aba9), [Commit](https://github.com/open-webui/open-webui/commit/02f7c3258b62970ce79716f75d15467a96565054)
+- 📎 Channel message input now supports direct paste functionality for images and files from the clipboard, streamlining content sharing workflows. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/6549fc839f86c40c26c2ef4dedcaf763a9304418)
+- ⚙️ Models can now be configured with default features (Web Search, Image Generation) and filters that automatically activate when a user selects the model. [Commit](https://github.com/open-webui/open-webui/commit/9a555478273355a5177bfc7f7211c64778e4c8de), [Commit](https://github.com/open-webui/open-webui/commit/384a53b339820068e92f7eaea0d9f3e0536c19c2), [Commit](https://github.com/open-webui/open-webui/commit/d7f43bfc1a30c065def8c50d77c2579c1a3c5c67), [Commit](https://github.com/open-webui/open-webui/commit/6a67a2217cc5946ad771e479e3a37ac213210748)
+- 💬 The ability to reference other chats as context within a conversation was added via the attachment menu. [Commit](https://github.com/open-webui/open-webui/commit/e097bbdf11ae4975c622e086df00d054291cdeb3), [Commit](https://github.com/open-webui/open-webui/commit/f3cd2ffb18e7dedbe88430f9ae7caa6b3cfd79d0), [Commit](https://github.com/open-webui/open-webui/commit/74263c872c5d574a9bb0944d7984f748dc772dba), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aa8ab349ed2fcb46d1cf994b9c0de2ec2ea35d0d), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/025eef754f0d46789981defd473d001e3b1d0ca2)
+- 🎨 The command suggestion UI for prompts ('/'), models ('@'), and knowledge ('#') was completely overhauled with a more responsive and keyboard-navigable interface. [Commit](https://github.com/open-webui/open-webui/commit/6b69c4da0fb9329ccf7024483960e070cf52ccab), [Commit](https://github.com/open-webui/open-webui/commit/06a6855f844456eceaa4d410c93379460e208202), [Commit](https://github.com/open-webui/open-webui/commit/c55f5578280b936cf581a743df3703e3db1afd54), [Commit](https://github.com/open-webui/open-webui/commit/f68d1ba394d4423d369f827894cde99d760b2402)
+- 👥 User and channel suggestions were added to the mention system, enabling '@' mentions for users and models, and '#' mentions for channels with searchable user lookup and clickable navigation. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/bbd1d2b58c89b35daea234f1fc9208f2af840899), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aef1e06f0bb72065a25579c982dd49157e320268), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/779db74d7e9b7b00d099b7d65cfbc8a831e74690)
+- 📁 Folder functionality was enhanced with custom background image support, improved drag-and-drop capabilities for moving folders to root level, and better menu interactions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2a234829f5dfdfde27fdfd30591caa908340efb4), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2b1ee8b0dc5f7c0caaafdd218f20705059fa72e2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b1e5bc8e490745f701909c19b6a444b67c04660e), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3e584132686372dfeef187596a7c557aa5f48308)
+- ☁️ OneDrive integration configuration now supports selecting between personal and work/school account types via ENABLE_ONEDRIVE_PERSONAL and ENABLE_ONEDRIVE_BUSINESS environment variables. [#17354](https://github.com/open-webui/open-webui/pull/17354), [Commit](https://github.com/open-webui/open-webui/commit/e1e3009a30f9808ce06582d81a60e391f5ca09ec), [Docs:#697](https://github.com/open-webui/docs/pull/697)
+- ⚡ Mermaid.js is now dynamically loaded on demand, significantly reducing first-screen loading time and improving initial page performance. [#17476](https://github.com/open-webui/open-webui/issues/17476), [#17477](https://github.com/open-webui/open-webui/pull/17477)
+- ⚡ Azure MSAL browser library is now dynamically loaded on demand, reducing initial bundle size by 730KB and improving first-screen loading speed. [#17479](https://github.com/open-webui/open-webui/pull/17479)
+- ⚡ CodeEditor component is now dynamically loaded on demand, reducing initial bundle size by 1MB and improving first-screen loading speed. [#17498](https://github.com/open-webui/open-webui/pull/17498)
+- ⚡ Hugging Face Transformers library is now dynamically loaded on demand, reducing initial bundle size by 1.9MB and improving first-screen loading speed. [#17499](https://github.com/open-webui/open-webui/pull/17499)
+- ⚡ jsPDF and html2canvas-pro libraries are now dynamically loaded on demand, reducing initial bundle size by 980KB and improving first-screen loading speed. [#17502](https://github.com/open-webui/open-webui/pull/17502)
+- ⚡ Leaflet mapping library is now dynamically loaded on demand, reducing initial bundle size by 454KB and improving first-screen loading speed. [#17503](https://github.com/open-webui/open-webui/pull/17503)
+- 📊 OpenTelemetry metrics collection was enhanced to properly handle HTTP 500 errors and ensure metrics are recorded even during exceptions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b14617a653c6bdcfd3102c12f971924fd1faf572)
+- 🔒 OAuth token retrieval logic was refactored, improving the reliability and consistency of authentication handling across the backend. [Commit](https://github.com/open-webui/open-webui/commit/6c0a5fa91cdbf6ffb74667ee61ca96bebfdfbc50)
+- 💻 Code block output processing was improved to handle Python execution results more reliably, along with refined visual styling and button layouts. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0e5320c39e308ff97f2ca9e289618af12479eb6e)
+- ⚡ Message input processing was optimized to skip unnecessary text variable handling when input is empty, improving performance. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e1386fe80b77126a12dabc4ad058abe9b024b275)
+- 📄 Individual chat PDF export was added to the sidebar chat menu, allowing users to export single conversations as PDF documents with both stylized and plain text options. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d041d58bb619689cd04a391b4f8191b23941ca62)
+- 🛠️ Function validation was enhanced with improved valve validation and better error handling during function loading and synchronization. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e66e0526ed6a116323285f79f44237538b6c75e6), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8edfd29102e0a61777b23d3575eaa30be37b59a5)
+- 🔔 Notification toast interaction was enhanced with drag detection to prevent accidental clicks and added keyboard support for accessibility. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/621e7679c427b6f0efa85f95235319238bf171ad)
+- 🗓️ Improved date and time formatting dynamically adapts to the selected language, ensuring consistent localization across the UI. [#17409](https://github.com/open-webui/open-webui/pull/17409), [Commit](https://github.com/open-webui/open-webui/commit/2227f24bd6d861b1fad8d2cabacf7d62ce137d0c)
+- 🔒 Feishu SSO integration was added, allowing users to authenticate via Feishu. [#17284](https://github.com/open-webui/open-webui/pull/17284), [Docs:#685](https://github.com/open-webui/docs/pull/685)
+- 🔠 Toggle filters in the chat input options menu are now sorted alphabetically for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/ca853ca4656180487afcd84230d214f91db52533)
+- 🎨 Long chat titles in the sidebar are now truncated to prevent text overflow and maintain a clean layout. [#17356](https://github.com/open-webui/open-webui/pull/17356)
+- 🎨 Temporary chat interface design was refined with improved layout and visual consistency. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/67549dcadd670285d491bd41daf3d081a70fd094), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2ca34217e68f3b439899c75881dfb050f49c9eb2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/fb02ec52a5df3f58b53db4ab3a995c15f83503cd)
+- 🎨 Download icon consistency was improved across the entire interface by standardizing the icon component used in menus, functions, tools, and export features. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/596be451ece7e11b5cd25465d49670c27a1cb33f)
+- 🎨 Settings interface was enhanced with improved iconography and reorganized the 'Chats' section into 'Data Controls' for better clarity. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8bf0b40fdd978b5af6548a6e1fb3aabd90bcd5cd)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Finnish, German, Kabyle, Portuguese (Brazil), Simplified Chinese, Spanish (Spain), and Traditional Chinese (Taiwan) were enhanced and expanded.
+
+### Fixed
+
+- 📚 Knowledge base permission logic was corrected to ensure private collection owners can access their own content when embedding bypass is enabled. [#17432](https://github.com/open-webui/open-webui/issues/17432), [Commit](https://github.com/open-webui/open-webui/commit/a51f0c30ec1472d71487eab3e15d0351a2716b12)
+- ⚙️ Connection URL editing in Admin Settings now properly saves changes instead of reverting to original values, fixing issues with both Ollama and OpenAI-compatible endpoints. [#17435](https://github.com/open-webui/open-webui/issues/17435), [Commit](https://github.com/open-webui/open-webui/commit/e4c864de7eb0d577843a80688677ce3659d1f81f)
+- 📊 Usage information collection from Google models was corrected to handle providers that send usage data alongside content chunks instead of separately. [#17421](https://github.com/open-webui/open-webui/pull/17421), [Commit](https://github.com/open-webui/open-webui/commit/c2f98a4cd29ed738f395fef09c42ab8e73cd46a0)
+- ⚙️ Settings modal scrolling issue was resolved by moving image compression controls to a dedicated modal, preventing the main settings from becoming scrollable out of view. [#17474](https://github.com/open-webui/open-webui/issues/17474), [Commit](https://github.com/open-webui/open-webui/commit/fed5615c19b0045a55b0be426b468a57bfda4b66)
+- 📁 Folder click behavior was improved to prevent accidental actions by implementing proper double-click detection and timing delays for folder expansion and selection. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/19e3214997170eea6ee92452e8c778e04a28e396)
+- 🔐 Access control component reliability was improved with better null checking and error handling for group permissions and private access scenarios. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c8780a7f934c5e49a21b438f2f30232f83cf75d2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/32015c392dbc6b7367a6a91d9e173e675ea3402c)
+- 🔗 The citation modal now correctly displays and links to external web page sources in addition to internal documents. [Commit](https://github.com/open-webui/open-webui/commit/9208a84185a7e59524f00a7576667d493c3ac7d4)
+- 🔗 Web and YouTube attachment handling was fixed, ensuring their content is now reliably processed and included in the chat context for retrieval. [Commit](https://github.com/open-webui/open-webui/commit/210197fd438b52080cda5d6ce3d47b92cdc264c8)
+- 📂 Large file upload failures are resolved by correcting the processing logic for scenarios where document embedding is bypassed. [Commit](https://github.com/open-webui/open-webui/commit/051b6daa8299fd332503bd584563556e2ae6adab)
+- 🌐 Rich text input placeholder text now correctly updates when the interface language is switched, ensuring proper localization. [#17473](https://github.com/open-webui/open-webui/pull/17473), [Commit](https://github.com/open-webui/open-webui/commit/77358031f5077e6efe5cc08d8d4e5831c7cd1cd9)
+- 📊 Llama.cpp server timing metrics are now correctly parsed and displayed by fixing a typo in the response handling. [#17350](https://github.com/open-webui/open-webui/issues/17350), [Commit](https://github.com/open-webui/open-webui/commit/cf72f5503f39834b9da44ebbb426a3674dad0caa)
+- 🛠️ Filter functions with file_handler configuration now properly handle messages without file attachments, preventing runtime errors. [#17423](https://github.com/open-webui/open-webui/pull/17423)
+- 🔔 Channel notification delivery was fixed to properly handle background task execution and user access checking. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/1077b2ac8b96e49c2ad2620e76eb65bbb2a3a1f3)
+
+### Changed
+
+- 📝 Prompt template variables are now optional by default instead of being forced as required, allowing flexible workflows with optional metadata fields. [#17447](https://github.com/open-webui/open-webui/issues/17447), [Commit](https://github.com/open-webui/open-webui/commit/d5824b1b495fcf86e57171769bcec2a0f698b070), [Docs:#696](https://github.com/open-webui/docs/pull/696)
+- 🛠️ Direct external tool servers now require explicit user selection from the input interface instead of being automatically included in conversations, providing better control over tool usage. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0f04227c34ca32746c43a9323e2df32299fcb6af), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/99bba12de279dd55c55ded35b2e4f819af1c9ab5)
+- 📺 Widescreen mode option was removed from Channels interface, with all channel layouts now using full-width display. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d46b7b8f1b99a8054b55031fe935c8a16d5ec956)
+- 🎛️ The plain textarea input option was deprecated, and the custom text editor is now the standard for all chat inputs. [Commit](https://github.com/open-webui/open-webui/commit/153afd832ccd12a1e5fd99b085008d080872c161)
+
## [0.6.28] - 2025-09-10
### Added
diff --git a/LICENSE_NOTICE b/LICENSE_NOTICE
new file mode 100644
index 0000000000..4e00d46d9a
--- /dev/null
+++ b/LICENSE_NOTICE
@@ -0,0 +1,11 @@
+# Open WebUI Multi-License Notice
+
+This repository contains code governed by multiple licenses based on the date and origin of contribution:
+
+1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
+
+2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
+
+3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
+
+For details on which commits are covered by which license, refer to LICENSE_HISTORY.
diff --git a/README.md b/README.md
index 9b01496d9f..49c0a8d9d3 100644
--- a/README.md
+++ b/README.md
@@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
## License 📜
-This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
+This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
## Support 💬
diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py
index a6b4ff967e..2faa728e95 100644
--- a/backend/open_webui/config.py
+++ b/backend/open_webui/config.py
@@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]):
class AppConfig:
- _state: dict[str, PersistentConfig]
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
_redis_key_prefix: str
+ _state: dict[str, PersistentConfig]
+
def __init__(
self,
redis_url: Optional[str] = None,
@@ -233,9 +234,8 @@ class AppConfig:
redis_cluster: Optional[bool] = False,
redis_key_prefix: str = "open-webui",
):
- super().__setattr__("_state", {})
- super().__setattr__("_redis_key_prefix", redis_key_prefix)
if redis_url:
+ super().__setattr__("_redis_key_prefix", redis_key_prefix)
super().__setattr__(
"_redis",
get_redis_connection(
@@ -246,6 +246,8 @@ class AppConfig:
),
)
+ super().__setattr__("_state", {})
+
def __setattr__(self, key, value):
if isinstance(value, PersistentConfig):
self._state[key] = value
@@ -603,8 +605,8 @@ def load_oauth_providers():
OAUTH_PROVIDERS.clear()
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
- def google_oauth_register(client: OAuth):
- client.register(
+ def google_oauth_register(oauth: OAuth):
+ return oauth.register(
name="google",
client_id=GOOGLE_CLIENT_ID.value,
client_secret=GOOGLE_CLIENT_SECRET.value,
@@ -631,8 +633,8 @@ def load_oauth_providers():
and MICROSOFT_CLIENT_TENANT_ID.value
):
- def microsoft_oauth_register(client: OAuth):
- client.register(
+ def microsoft_oauth_register(oauth: OAuth):
+ return oauth.register(
name="microsoft",
client_id=MICROSOFT_CLIENT_ID.value,
client_secret=MICROSOFT_CLIENT_SECRET.value,
@@ -656,8 +658,8 @@ def load_oauth_providers():
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
- def github_oauth_register(client: OAuth):
- client.register(
+ def github_oauth_register(oauth: OAuth):
+ return oauth.register(
name="github",
client_id=GITHUB_CLIENT_ID.value,
client_secret=GITHUB_CLIENT_SECRET.value,
@@ -688,7 +690,7 @@ def load_oauth_providers():
and OPENID_PROVIDER_URL.value
):
- def oidc_oauth_register(client: OAuth):
+ def oidc_oauth_register(oauth: OAuth):
client_kwargs = {
"scope": OAUTH_SCOPES.value,
**(
@@ -714,7 +716,7 @@ def load_oauth_providers():
% ("S256", OAUTH_CODE_CHALLENGE_METHOD.value)
)
- client.register(
+ return oauth.register(
name="oidc",
client_id=OAUTH_CLIENT_ID.value,
client_secret=OAUTH_CLIENT_SECRET.value,
@@ -731,8 +733,8 @@ def load_oauth_providers():
if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
- def feishu_oauth_register(client: OAuth):
- client.register(
+ def feishu_oauth_register(oauth: OAuth):
+ return oauth.register(
name="feishu",
client_id=FEISHU_CLIENT_ID.value,
client_secret=FEISHU_CLIENT_SECRET.value,
@@ -1215,6 +1217,11 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
== "true"
)
+USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
+ os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
+ == "true"
+)
+
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
@@ -1352,6 +1359,7 @@ DEFAULT_USER_PERMISSIONS = {
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
+ "public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
@@ -1997,16 +2005,23 @@ if VECTOR_DB == "chroma":
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
# Milvus
-
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
-
MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW")
MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE")
MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16"))
MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100"))
MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128"))
+MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get("MILVUS_DISKANN_MAX_DEGREE", "56"))
+MILVUS_DISKANN_SEARCH_LIST_SIZE = int(
+ os.environ.get("MILVUS_DISKANN_SEARCH_LIST_SIZE", "100")
+)
+ENABLE_MILVUS_MULTITENANCY_MODE = (
+ os.environ.get("ENABLE_MILVUS_MULTITENANCY_MODE", "false").lower() == "true"
+)
+# Hyphens not allowed, need to use underscores in collection names
+MILVUS_COLLECTION_PREFIX = os.environ.get("MILVUS_COLLECTION_PREFIX", "open_webui")
# Qdrant
QDRANT_URI = os.environ.get("QDRANT_URI", None)
@@ -2169,10 +2184,20 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
)
-ONEDRIVE_CLIENT_ID = PersistentConfig(
- "ONEDRIVE_CLIENT_ID",
- "onedrive.client_id",
- os.environ.get("ONEDRIVE_CLIENT_ID", ""),
+
+ENABLE_ONEDRIVE_PERSONAL = (
+ os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
+)
+ENABLE_ONEDRIVE_BUSINESS = (
+ os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
+)
+
+ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "")
+ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
+ "ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
+)
+ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
+ "ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
)
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
@@ -2285,6 +2310,18 @@ DOCLING_SERVER_URL = PersistentConfig(
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
)
+docling_params = os.getenv("DOCLING_PARAMS", "")
+try:
+ docling_params = json.loads(docling_params)
+except json.JSONDecodeError:
+ docling_params = {}
+
+DOCLING_PARAMS = PersistentConfig(
+ "DOCLING_PARAMS",
+ "rag.docling_params",
+ docling_params,
+)
+
DOCLING_DO_OCR = PersistentConfig(
"DOCLING_DO_OCR",
"rag.docling_do_ocr",
@@ -2778,6 +2815,12 @@ WEB_SEARCH_TRUST_ENV = PersistentConfig(
)
+OLLAMA_CLOUD_WEB_SEARCH_API_KEY = PersistentConfig(
+ "OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
+ "rag.web.search.ollama_cloud_api_key",
+ os.getenv("OLLAMA_CLOUD_API_KEY", ""),
+)
+
SEARXNG_QUERY_URL = PersistentConfig(
"SEARXNG_QUERY_URL",
"rag.web.search.searxng_query_url",
@@ -3353,6 +3396,19 @@ AUDIO_TTS_OPENAI_API_KEY = PersistentConfig(
os.getenv("AUDIO_TTS_OPENAI_API_KEY", OPENAI_API_KEY),
)
+audio_tts_openai_params = os.getenv("AUDIO_TTS_OPENAI_PARAMS", "")
+try:
+ audio_tts_openai_params = json.loads(audio_tts_openai_params)
+except json.JSONDecodeError:
+ audio_tts_openai_params = {}
+
+AUDIO_TTS_OPENAI_PARAMS = PersistentConfig(
+ "AUDIO_TTS_OPENAI_PARAMS",
+ "audio.tts.openai.params",
+ audio_tts_openai_params,
+)
+
+
AUDIO_TTS_API_KEY = PersistentConfig(
"AUDIO_TTS_API_KEY",
"audio.tts.api_key",
diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py
index b4fdc97d82..e02424f969 100644
--- a/backend/open_webui/env.py
+++ b/backend/open_webui/env.py
@@ -474,6 +474,10 @@ ENABLE_OAUTH_ID_TOKEN_COOKIE = (
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
)
+OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get(
+ "OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY
+)
+
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
@@ -547,16 +551,16 @@ else:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
- "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
+ "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
)
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
- CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
+ CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
else:
try:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
except Exception:
- CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
+ CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
####################################
diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py
index 7224d28113..316efe18e7 100644
--- a/backend/open_webui/functions.py
+++ b/backend/open_webui/functions.py
@@ -19,6 +19,7 @@ from fastapi import (
from starlette.responses import Response, StreamingResponse
+from open_webui.constants import ERROR_MESSAGES
from open_webui.socket.main import (
get_event_call,
get_event_emitter,
@@ -60,8 +61,20 @@ def get_function_module_by_id(request: Request, pipe_id: str):
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
+ Valves = function_module.Valves
valves = Functions.get_function_valves_by_id(pipe_id)
- function_module.valves = function_module.Valves(**(valves if valves else {}))
+
+ if valves:
+ try:
+ function_module.valves = Valves(
+ **{k: v for k, v in valves.items() if v is not None}
+ )
+ except Exception as e:
+ log.exception(f"Error loading valves for function {pipe_id}: {e}")
+ raise e
+ else:
+ function_module.valves = Valves()
+
return function_module
@@ -70,65 +83,75 @@ async def get_function_models(request):
pipe_models = []
for pipe in pipes:
- function_module = get_function_module_by_id(request, pipe.id)
+ try:
+ function_module = get_function_module_by_id(request, pipe.id)
- # Check if function is a manifold
- if hasattr(function_module, "pipes"):
- sub_pipes = []
+ has_user_valves = False
+ if hasattr(function_module, "UserValves"):
+ has_user_valves = True
- # Handle pipes being a list, sync function, or async function
- try:
- if callable(function_module.pipes):
- if asyncio.iscoroutinefunction(function_module.pipes):
- sub_pipes = await function_module.pipes()
- else:
- sub_pipes = function_module.pipes()
- else:
- sub_pipes = function_module.pipes
- except Exception as e:
- log.exception(e)
+ # Check if function is a manifold
+ if hasattr(function_module, "pipes"):
sub_pipes = []
- log.debug(
- f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
- )
+ # Handle pipes being a list, sync function, or async function
+ try:
+ if callable(function_module.pipes):
+ if asyncio.iscoroutinefunction(function_module.pipes):
+ sub_pipes = await function_module.pipes()
+ else:
+ sub_pipes = function_module.pipes()
+ else:
+ sub_pipes = function_module.pipes
+ except Exception as e:
+ log.exception(e)
+ sub_pipes = []
- for p in sub_pipes:
- sub_pipe_id = f'{pipe.id}.{p["id"]}'
- sub_pipe_name = p["name"]
+ log.debug(
+ f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
+ )
- if hasattr(function_module, "name"):
- sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
+ for p in sub_pipes:
+ sub_pipe_id = f'{pipe.id}.{p["id"]}'
+ sub_pipe_name = p["name"]
- pipe_flag = {"type": pipe.type}
+ if hasattr(function_module, "name"):
+ sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
+
+ pipe_flag = {"type": pipe.type}
+
+ pipe_models.append(
+ {
+ "id": sub_pipe_id,
+ "name": sub_pipe_name,
+ "object": "model",
+ "created": pipe.created_at,
+ "owned_by": "openai",
+ "pipe": pipe_flag,
+ "has_user_valves": has_user_valves,
+ }
+ )
+ else:
+ pipe_flag = {"type": "pipe"}
+
+ log.debug(
+ f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
+ )
pipe_models.append(
{
- "id": sub_pipe_id,
- "name": sub_pipe_name,
+ "id": pipe.id,
+ "name": pipe.name,
"object": "model",
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
+ "has_user_valves": has_user_valves,
}
)
- else:
- pipe_flag = {"type": "pipe"}
-
- log.debug(
- f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
- )
-
- pipe_models.append(
- {
- "id": pipe.id,
- "name": pipe.name,
- "object": "model",
- "created": pipe.created_at,
- "owned_by": "openai",
- "pipe": pipe_flag,
- }
- )
+ except Exception as e:
+ log.exception(e)
+ continue
return pipe_models
@@ -222,7 +245,7 @@ async def generate_function_chat_completion(
oauth_token = None
try:
if request.cookies.get("oauth_session_id", None):
- oauth_token = request.app.state.oauth_manager.get_oauth_token(
+ oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py
index 6b536c78bc..6b1d8e5d82 100644
--- a/backend/open_webui/main.py
+++ b/backend/open_webui/main.py
@@ -8,6 +8,7 @@ import shutil
import sys
import time
import random
+import re
from uuid import uuid4
@@ -50,6 +51,11 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response, StreamingResponse
from starlette.datastructures import Headers
+from starsessions import (
+ SessionMiddleware as StarSessionsMiddleware,
+ SessionAutoloadMiddleware,
+)
+from starsessions.stores.redis import RedisStore
from open_webui.utils import logger
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
@@ -110,9 +116,6 @@ from open_webui.config import (
OLLAMA_API_CONFIGS,
# OpenAI
ENABLE_OPENAI_API,
- ONEDRIVE_CLIENT_ID,
- ONEDRIVE_SHAREPOINT_URL,
- ONEDRIVE_SHAREPOINT_TENANT_ID,
OPENAI_API_BASE_URLS,
OPENAI_API_KEYS,
OPENAI_API_CONFIGS,
@@ -172,13 +175,14 @@ from open_webui.config import (
AUDIO_STT_AZURE_LOCALES,
AUDIO_STT_AZURE_BASE_URL,
AUDIO_STT_AZURE_MAX_SPEAKERS,
- AUDIO_TTS_API_KEY,
AUDIO_TTS_ENGINE,
AUDIO_TTS_MODEL,
+ AUDIO_TTS_VOICE,
AUDIO_TTS_OPENAI_API_BASE_URL,
AUDIO_TTS_OPENAI_API_KEY,
+ AUDIO_TTS_OPENAI_PARAMS,
+ AUDIO_TTS_API_KEY,
AUDIO_TTS_SPLIT_ON,
- AUDIO_TTS_VOICE,
AUDIO_TTS_AZURE_SPEECH_REGION,
AUDIO_TTS_AZURE_SPEECH_BASE_URL,
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
@@ -244,6 +248,7 @@ from open_webui.config import (
EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL,
DOCLING_SERVER_URL,
+ DOCLING_PARAMS,
DOCLING_DO_OCR,
DOCLING_FORCE_OCR,
DOCLING_OCR_ENGINE,
@@ -272,6 +277,7 @@ from open_webui.config import (
WEB_SEARCH_CONCURRENT_REQUESTS,
WEB_SEARCH_TRUST_ENV,
WEB_SEARCH_DOMAIN_FILTER_LIST,
+ OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
JINA_API_KEY,
SEARCHAPI_API_KEY,
SEARCHAPI_ENGINE,
@@ -303,14 +309,17 @@ from open_webui.config import (
GOOGLE_PSE_ENGINE_ID,
GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY,
- ONEDRIVE_CLIENT_ID,
+ ENABLE_ONEDRIVE_INTEGRATION,
+ ONEDRIVE_CLIENT_ID_PERSONAL,
+ ONEDRIVE_CLIENT_ID_BUSINESS,
ONEDRIVE_SHAREPOINT_URL,
ONEDRIVE_SHAREPOINT_TENANT_ID,
+ ENABLE_ONEDRIVE_PERSONAL,
+ ENABLE_ONEDRIVE_BUSINESS,
ENABLE_RAG_HYBRID_SEARCH,
ENABLE_RAG_LOCAL_WEB_FETCH,
ENABLE_WEB_LOADER_SSL_VERIFICATION,
ENABLE_GOOGLE_DRIVE_INTEGRATION,
- ENABLE_ONEDRIVE_INTEGRATION,
UPLOAD_DIR,
EXTERNAL_WEB_SEARCH_URL,
EXTERNAL_WEB_SEARCH_API_KEY,
@@ -448,6 +457,7 @@ from open_webui.utils.models import (
get_all_models,
get_all_base_models,
check_model_access,
+ get_filtered_models,
)
from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler,
@@ -466,7 +476,12 @@ from open_webui.utils.auth import (
get_verified_user,
)
from open_webui.utils.plugin import install_tool_and_function_dependencies
-from open_webui.utils.oauth import OAuthManager
+from open_webui.utils.oauth import (
+ OAuthManager,
+ OAuthClientManager,
+ decrypt_data,
+ OAuthClientInformationFull,
+)
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.utils.redis import get_redis_connection
@@ -596,9 +611,14 @@ app = FastAPI(
lifespan=lifespan,
)
+# For Open WebUI OIDC/OAuth2
oauth_manager = OAuthManager(app)
app.state.oauth_manager = oauth_manager
+# For Integrations
+oauth_client_manager = OAuthClientManager(app)
+app.state.oauth_client_manager = oauth_client_manager
+
app.state.instance_id = None
app.state.config = AppConfig(
redis_url=REDIS_URL,
@@ -817,6 +837,7 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
+app.state.config.DOCLING_PARAMS = DOCLING_PARAMS
app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR
app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
@@ -882,6 +903,8 @@ app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
+
+app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
app.state.config.YACY_USERNAME = YACY_USERNAME
@@ -1076,11 +1099,15 @@ app.state.config.AUDIO_STT_AZURE_LOCALES = AUDIO_STT_AZURE_LOCALES
app.state.config.AUDIO_STT_AZURE_BASE_URL = AUDIO_STT_AZURE_BASE_URL
app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = AUDIO_STT_AZURE_MAX_SPEAKERS
-app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL
-app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY
app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE
+
app.state.config.TTS_MODEL = AUDIO_TTS_MODEL
app.state.config.TTS_VOICE = AUDIO_TTS_VOICE
+
+app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL
+app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY
+app.state.config.TTS_OPENAI_PARAMS = AUDIO_TTS_OPENAI_PARAMS
+
app.state.config.TTS_API_KEY = AUDIO_TTS_API_KEY
app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON
@@ -1151,12 +1178,32 @@ class RedirectMiddleware(BaseHTTPMiddleware):
path = request.url.path
query_params = dict(parse_qs(urlparse(str(request.url)).query))
+ redirect_params = {}
+
# Check for the specific watch path and the presence of 'v' parameter
if path.endswith("/watch") and "v" in query_params:
# Extract the first 'v' parameter
- video_id = query_params["v"][0]
- encoded_video_id = urlencode({"youtube": video_id})
- redirect_url = f"/?{encoded_video_id}"
+ youtube_video_id = query_params["v"][0]
+ redirect_params["youtube"] = youtube_video_id
+
+ if "shared" in query_params and len(query_params["shared"]) > 0:
+ # PWA share_target support
+
+ text = query_params["shared"][0]
+ if text:
+ urls = re.match(r"https://\S+", text)
+ if urls:
+ from open_webui.retrieval.loaders.youtube import _parse_video_id
+
+ if youtube_video_id := _parse_video_id(urls[0]):
+ redirect_params["youtube"] = youtube_video_id
+ else:
+ redirect_params["load-url"] = urls[0]
+ else:
+ redirect_params["q"] = text
+
+ if redirect_params:
+ redirect_url = f"/?{urlencode(redirect_params)}"
return RedirectResponse(url=redirect_url)
# Proceed with the normal flow of other requests
@@ -1291,33 +1338,6 @@ if audit_level != AuditLevel.NONE:
async def get_models(
request: Request, refresh: bool = False, user=Depends(get_verified_user)
):
- def get_filtered_models(models, user):
- filtered_models = []
- for model in models:
- if model.get("arena"):
- if has_access(
- user.id,
- type="read",
- access_control=model.get("info", {})
- .get("meta", {})
- .get("access_control", {}),
- ):
- filtered_models.append(model)
- continue
-
- model_info = Models.get_model_by_id(model["id"])
- if model_info:
- if (
- (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
- or user.id == model_info.user_id
- or has_access(
- user.id, type="read", access_control=model_info.access_control
- )
- ):
- filtered_models.append(model)
-
- return filtered_models
-
all_models = await get_all_models(request, refresh=refresh, user=user)
models = []
@@ -1353,12 +1373,7 @@ async def get_models(
)
)
- # Filter out models that the user does not have access to
- if (
- user.role == "user"
- or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
- ) and not BYPASS_MODEL_ACCESS_CONTROL:
- models = get_filtered_models(models, user)
+ models = get_filtered_models(models, user)
log.debug(
f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}"
@@ -1487,7 +1502,7 @@ async def chat_completion(
}
if metadata.get("chat_id") and (user and user.role != "admin"):
- if metadata["chat_id"] != "local":
+ if not metadata["chat_id"].startswith("local:"):
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
if chat is None:
raise HTTPException(
@@ -1514,13 +1529,14 @@ async def chat_completion(
response = await chat_completion_handler(request, form_data, user)
if metadata.get("chat_id") and metadata.get("message_id"):
try:
- Chats.upsert_message_to_chat_by_id_and_message_id(
- metadata["chat_id"],
- metadata["message_id"],
- {
- "model": model_id,
- },
- )
+ if not metadata["chat_id"].startswith("local:"):
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ {
+ "model": model_id,
+ },
+ )
except:
pass
@@ -1541,13 +1557,14 @@ async def chat_completion(
if metadata.get("chat_id") and metadata.get("message_id"):
# Update the chat message with the error
try:
- Chats.upsert_message_to_chat_by_id_and_message_id(
- metadata["chat_id"],
- metadata["message_id"],
- {
- "error": {"content": str(e)},
- },
- )
+ if not metadata["chat_id"].startswith("local:"):
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ {
+ "error": {"content": str(e)},
+ },
+ )
event_emitter = get_event_emitter(metadata)
await event_emitter(
@@ -1562,6 +1579,14 @@ async def chat_completion(
except:
pass
+ finally:
+ try:
+ if mcp_clients := metadata.get("mcp_clients"):
+ for client in mcp_clients.values():
+ await client.disconnect()
+ except Exception as e:
+ log.debug(f"Error cleaning up: {e}")
+ pass
if (
metadata.get("session_id")
@@ -1730,6 +1755,14 @@ async def get_app_config(request: Request):
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
+ **(
+ {
+ "enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
+ "enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS,
+ }
+ if app.state.config.ENABLE_ONEDRIVE_INTEGRATION
+ else {}
+ ),
}
if user is not None
else {}
@@ -1767,7 +1800,8 @@ async def get_app_config(request: Request):
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
"onedrive": {
- "client_id": ONEDRIVE_CLIENT_ID.value,
+ "client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
+ "client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
},
@@ -1887,17 +1921,76 @@ async def get_current_usage(user=Depends(get_verified_user)):
# OAuth Login & Callback
############################
-# SessionMiddleware is used by authlib for oauth
-if len(OAUTH_PROVIDERS) > 0:
+
+# Initialize OAuth client manager with any MCP tool servers using OAuth 2.1
+if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
+ for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS:
+ if tool_server_connection.get("type", "openapi") == "mcp":
+ server_id = tool_server_connection.get("info", {}).get("id")
+ auth_type = tool_server_connection.get("auth_type", "none")
+ if server_id and auth_type == "oauth_2.1":
+ oauth_client_info = tool_server_connection.get("info", {}).get(
+ "oauth_client_info", ""
+ )
+
+ oauth_client_info = decrypt_data(oauth_client_info)
+ app.state.oauth_client_manager.add_client(
+ f"mcp:{server_id}", OAuthClientInformationFull(**oauth_client_info)
+ )
+
+try:
+ if REDIS_URL:
+ redis_session_store = RedisStore(
+ url=REDIS_URL,
+ prefix=(f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"),
+ )
+
+ app.add_middleware(SessionAutoloadMiddleware)
+ app.add_middleware(
+ StarSessionsMiddleware,
+ store=redis_session_store,
+ cookie_name="owui-session",
+ cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
+ cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
+ )
+ log.info("Using Redis for session")
+ else:
+ raise ValueError("No Redis URL provided")
+except Exception as e:
app.add_middleware(
SessionMiddleware,
secret_key=WEBUI_SECRET_KEY,
- session_cookie="oui-session",
+ session_cookie="owui-session",
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
https_only=WEBUI_SESSION_COOKIE_SECURE,
)
+@app.get("/oauth/clients/{client_id}/authorize")
+async def oauth_client_authorize(
+ client_id: str,
+ request: Request,
+ response: Response,
+ user=Depends(get_verified_user),
+):
+ return await oauth_client_manager.handle_authorize(request, client_id=client_id)
+
+
+@app.get("/oauth/clients/{client_id}/callback")
+async def oauth_client_callback(
+ client_id: str,
+ request: Request,
+ response: Response,
+ user=Depends(get_verified_user),
+):
+ return await oauth_client_manager.handle_callback(
+ request,
+ client_id=client_id,
+ user_id=user.id if user else None,
+ response=response,
+ )
+
+
@app.get("/oauth/{provider}/login")
async def oauth_login(provider: str, request: Request):
return await oauth_manager.handle_login(request, provider)
@@ -1909,8 +2002,9 @@ async def oauth_login(provider: str, request: Request):
# - This is considered insecure in general, as OAuth providers do not always verify email addresses
# 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user
# - Email addresses are considered unique, so we fail registration if the email address is already taken
-@app.get("/oauth/{provider}/callback")
-async def oauth_callback(provider: str, request: Request, response: Response):
+@app.get("/oauth/{provider}/login/callback")
+@app.get("/oauth/{provider}/callback") # Legacy endpoint
+async def oauth_login_callback(provider: str, request: Request, response: Response):
return await oauth_manager.handle_callback(request, provider, response)
@@ -1940,6 +2034,11 @@ async def get_manifest_json():
"purpose": "maskable",
},
],
+ "share_target": {
+ "action": "/",
+ "method": "GET",
+ "params": {"text": "shared"},
+ },
}
diff --git a/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py b/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py
new file mode 100644
index 0000000000..dd2b7d1a68
--- /dev/null
+++ b/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py
@@ -0,0 +1,34 @@
+"""Add reply_to_id column to message
+
+Revision ID: a5c220713937
+Revises: 38d63c18f30f
+Create Date: 2025-09-27 02:24:18.058455
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "a5c220713937"
+down_revision: Union[str, None] = "38d63c18f30f"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Add 'reply_to_id' column to the 'message' table for replying to messages
+ op.add_column(
+ "message",
+ sa.Column("reply_to_id", sa.Text(), nullable=True),
+ )
+ pass
+
+
+def downgrade() -> None:
+ # Remove 'reply_to_id' column from the 'message' table
+ op.drop_column("message", "reply_to_id")
+
+ pass
diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py
index 92f238c3a0..e75266be78 100644
--- a/backend/open_webui/models/channels.py
+++ b/backend/open_webui/models/channels.py
@@ -57,6 +57,10 @@ class ChannelModel(BaseModel):
####################
+class ChannelResponse(ChannelModel):
+ write_access: bool = False
+
+
class ChannelForm(BaseModel):
name: str
description: Optional[str] = None
diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py
index cadb5a3a79..98b1166ce4 100644
--- a/backend/open_webui/models/chats.py
+++ b/backend/open_webui/models/chats.py
@@ -366,6 +366,15 @@ class ChatTable:
except Exception:
return False
+ def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
+ try:
+ with get_db() as db:
+ db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
+ db.commit()
+ return True
+ except Exception:
+ return False
+
def update_chat_share_id_by_id(
self, id: str, share_id: Optional[str]
) -> Optional[ChatModel]:
@@ -492,11 +501,16 @@ class ChatTable:
self,
user_id: str,
include_archived: bool = False,
+ include_folders: bool = False,
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[ChatTitleIdResponse]:
with get_db() as db:
- query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
+ query = db.query(Chat).filter_by(user_id=user_id)
+
+ if not include_folders:
+ query = query.filter_by(folder_id=None)
+
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
if not include_archived:
@@ -805,7 +819,7 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_id_and_user_id(
- self, folder_id: str, user_id: str
+ self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
@@ -814,6 +828,11 @@ class ChatTable:
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]
@@ -943,6 +962,16 @@ class ChatTable:
return count
+ def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int:
+ with get_db() as db:
+ query = db.query(Chat).filter_by(user_id=user_id)
+
+ query = query.filter_by(folder_id=folder_id)
+ count = query.count()
+
+ log.info(f"Count of chats for folder '{folder_id}': {count}")
+ return count
+
def delete_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str
) -> bool:
diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py
index 57978225d4..bf07b5f86f 100644
--- a/backend/open_webui/models/files.py
+++ b/backend/open_webui/models/files.py
@@ -130,6 +130,17 @@ class FilesTable:
except Exception:
return None
+ def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
+ with get_db() as db:
+ try:
+ file = db.query(File).filter_by(id=id, user_id=user_id).first()
+ if file:
+ return FileModel.model_validate(file)
+ else:
+ return None
+ except Exception:
+ return None
+
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
with get_db() as db:
try:
diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py
index c876645750..45f8247080 100644
--- a/backend/open_webui/models/folders.py
+++ b/backend/open_webui/models/folders.py
@@ -50,6 +50,20 @@ class FolderModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
+class FolderMetadataResponse(BaseModel):
+ icon: Optional[str] = None
+
+
+class FolderNameIdResponse(BaseModel):
+ id: str
+ name: str
+ meta: Optional[FolderMetadataResponse] = None
+ parent_id: Optional[str] = None
+ is_expanded: bool = False
+ created_at: int
+ updated_at: int
+
+
####################
# Forms
####################
diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py
index a27ae52519..8b0027b8e7 100644
--- a/backend/open_webui/models/messages.py
+++ b/backend/open_webui/models/messages.py
@@ -5,6 +5,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
+from open_webui.models.users import Users, UserNameResponse
from pydantic import BaseModel, ConfigDict
@@ -43,6 +44,7 @@ class Message(Base):
user_id = Column(Text)
channel_id = Column(Text, nullable=True)
+ reply_to_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
content = Column(Text)
@@ -60,6 +62,7 @@ class MessageModel(BaseModel):
user_id: str
channel_id: Optional[str] = None
+ reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
content: str
@@ -77,6 +80,7 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel):
content: str
+ reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
@@ -88,7 +92,15 @@ class Reactions(BaseModel):
count: int
-class MessageResponse(MessageModel):
+class MessageUserResponse(MessageModel):
+ user: Optional[UserNameResponse] = None
+
+
+class MessageReplyToResponse(MessageUserResponse):
+ reply_to_message: Optional[MessageUserResponse] = None
+
+
+class MessageResponse(MessageReplyToResponse):
latest_reply_at: Optional[int]
reply_count: int
reactions: list[Reactions]
@@ -107,6 +119,7 @@ class MessageTable:
"id": id,
"user_id": user_id,
"channel_id": channel_id,
+ "reply_to_id": form_data.reply_to_id,
"parent_id": form_data.parent_id,
"content": form_data.content,
"data": form_data.data,
@@ -128,19 +141,32 @@ class MessageTable:
if not message:
return None
- reactions = self.get_reactions_by_message_id(id)
- replies = self.get_replies_by_message_id(id)
+ reply_to_message = (
+ self.get_message_by_id(message.reply_to_id)
+ if message.reply_to_id
+ else None
+ )
- return MessageResponse(
- **{
+ reactions = self.get_reactions_by_message_id(id)
+ thread_replies = self.get_thread_replies_by_message_id(id)
+
+ user = Users.get_user_by_id(message.user_id)
+ return MessageResponse.model_validate(
+ {
**MessageModel.model_validate(message).model_dump(),
- "latest_reply_at": replies[0].created_at if replies else None,
- "reply_count": len(replies),
+ "user": user.model_dump() if user else None,
+ "reply_to_message": (
+ reply_to_message.model_dump() if reply_to_message else None
+ ),
+ "latest_reply_at": (
+ thread_replies[0].created_at if thread_replies else None
+ ),
+ "reply_count": len(thread_replies),
"reactions": reactions,
}
)
- def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
+ def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
with get_db() as db:
all_messages = (
db.query(Message)
@@ -148,7 +174,27 @@ class MessageTable:
.order_by(Message.created_at.desc())
.all()
)
- return [MessageModel.model_validate(message) for message in all_messages]
+
+ messages = []
+ for message in all_messages:
+ reply_to_message = (
+ self.get_message_by_id(message.reply_to_id)
+ if message.reply_to_id
+ else None
+ )
+ messages.append(
+ MessageReplyToResponse.model_validate(
+ {
+ **MessageModel.model_validate(message).model_dump(),
+ "reply_to_message": (
+ reply_to_message.model_dump()
+ if reply_to_message
+ else None
+ ),
+ }
+ )
+ )
+ return messages
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
@@ -159,7 +205,7 @@ class MessageTable:
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
- ) -> list[MessageModel]:
+ ) -> list[MessageReplyToResponse]:
with get_db() as db:
all_messages = (
db.query(Message)
@@ -169,11 +215,31 @@ class MessageTable:
.limit(limit)
.all()
)
- return [MessageModel.model_validate(message) for message in all_messages]
+
+ messages = []
+ for message in all_messages:
+ reply_to_message = (
+ self.get_message_by_id(message.reply_to_id)
+ if message.reply_to_id
+ else None
+ )
+ messages.append(
+ MessageReplyToResponse.model_validate(
+ {
+ **MessageModel.model_validate(message).model_dump(),
+ "reply_to_message": (
+ reply_to_message.model_dump()
+ if reply_to_message
+ else None
+ ),
+ }
+ )
+ )
+ return messages
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
- ) -> list[MessageModel]:
+ ) -> list[MessageReplyToResponse]:
with get_db() as db:
message = db.get(Message, parent_id)
@@ -193,7 +259,26 @@ class MessageTable:
if len(all_messages) < limit:
all_messages.append(message)
- return [MessageModel.model_validate(message) for message in all_messages]
+ messages = []
+ for message in all_messages:
+ reply_to_message = (
+ self.get_message_by_id(message.reply_to_id)
+ if message.reply_to_id
+ else None
+ )
+ messages.append(
+ MessageReplyToResponse.model_validate(
+ {
+ **MessageModel.model_validate(message).model_dump(),
+ "reply_to_message": (
+ reply_to_message.model_dump()
+ if reply_to_message
+ else None
+ ),
+ }
+ )
+ )
+ return messages
def update_message_by_id(
self, id: str, form_data: MessageForm
@@ -201,8 +286,14 @@ class MessageTable:
with get_db() as db:
message = db.get(Message, id)
message.content = form_data.content
- message.data = form_data.data
- message.meta = form_data.meta
+ message.data = {
+ **(message.data if message.data else {}),
+ **(form_data.data if form_data.data else {}),
+ }
+ message.meta = {
+ **(message.meta if message.meta else {}),
+ **(form_data.meta if form_data.meta else {}),
+ }
message.updated_at = int(time.time_ns())
db.commit()
db.refresh(message)
diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py
index b61e820eae..f1b11f071e 100644
--- a/backend/open_webui/models/notes.py
+++ b/backend/open_webui/models/notes.py
@@ -2,6 +2,7 @@ import json
import time
import uuid
from typing import Optional
+from functools import lru_cache
from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups
@@ -110,20 +111,72 @@ class NoteTable:
return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_user_id(
+ self,
+ user_id: str,
+ skip: Optional[int] = None,
+ limit: Optional[int] = None,
+ ) -> list[NoteModel]:
+ with get_db() as db:
+ query = db.query(Note).filter(Note.user_id == user_id)
+ query = query.order_by(Note.updated_at.desc())
+
+ if skip is not None:
+ query = query.offset(skip)
+ if limit is not None:
+ query = query.limit(limit)
+
+ notes = query.all()
+ return [NoteModel.model_validate(note) for note in notes]
+
+ def get_notes_by_permission(
self,
user_id: str,
permission: str = "write",
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
- notes = self.get_notes(skip=skip, limit=limit)
- user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
- return [
- note
- for note in notes
- if note.user_id == user_id
- or has_access(user_id, permission, note.access_control, user_group_ids)
- ]
+ with get_db() as db:
+ user_groups = Groups.get_groups_by_member_id(user_id)
+ user_group_ids = {group.id for group in user_groups}
+
+ # Order newest-first. We stream to keep memory usage low.
+ query = (
+ db.query(Note)
+ .order_by(Note.updated_at.desc())
+ .execution_options(stream_results=True)
+ .yield_per(256)
+ )
+
+ results: list[NoteModel] = []
+ n_skipped = 0
+
+ for note in query:
+ # Fast-pass #1: owner
+ if note.user_id == user_id:
+ permitted = True
+ # Fast-pass #2: public/open
+ elif note.access_control is None:
+ # Technically this should mean public access for both read and write, but we'll only do read for now
+ # We might want to change this behavior later
+ permitted = permission == "read"
+ else:
+ permitted = has_access(
+ user_id, permission, note.access_control, user_group_ids
+ )
+
+ if not permitted:
+ continue
+
+ # Apply skip AFTER permission filtering so it counts only accessible notes
+ if skip and n_skipped < skip:
+ n_skipped += 1
+ continue
+
+ results.append(NoteModel.model_validate(note))
+ if limit is not None and len(results) >= limit:
+ break
+
+ return results
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db:
diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py
index 9fd5335ce5..81ce220384 100644
--- a/backend/open_webui/models/oauth_sessions.py
+++ b/backend/open_webui/models/oauth_sessions.py
@@ -176,6 +176,26 @@ class OAuthSessionTable:
log.error(f"Error getting OAuth session by ID: {e}")
return None
+ def get_session_by_provider_and_user_id(
+ self, provider: str, user_id: str
+ ) -> Optional[OAuthSessionModel]:
+ """Get OAuth session by provider and user ID"""
+ try:
+ with get_db() as db:
+ session = (
+ db.query(OAuthSession)
+ .filter_by(provider=provider, user_id=user_id)
+ .first()
+ )
+ if session:
+ session.token = self._decrypt_token(session.token)
+ return OAuthSessionModel.model_validate(session)
+
+ return None
+ except Exception as e:
+ log.error(f"Error getting OAuth session by provider and user ID: {e}")
+ return None
+
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user"""
try:
diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py
index 3a47fa008d..48f84b3ac4 100644
--- a/backend/open_webui/models/tools.py
+++ b/backend/open_webui/models/tools.py
@@ -95,6 +95,8 @@ class ToolResponse(BaseModel):
class ToolUserResponse(ToolResponse):
user: Optional[UserResponse] = None
+ model_config = ConfigDict(extra="allow")
+
class ToolForm(BaseModel):
id: str
diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py
index 620a746eed..05000744dd 100644
--- a/backend/open_webui/models/users.py
+++ b/backend/open_webui/models/users.py
@@ -107,11 +107,21 @@ class UserInfoResponse(BaseModel):
role: str
+class UserIdNameResponse(BaseModel):
+ id: str
+ name: str
+
+
class UserInfoListResponse(BaseModel):
users: list[UserInfoResponse]
total: int
+class UserIdNameListResponse(BaseModel):
+ users: list[UserIdNameResponse]
+ total: int
+
+
class UserResponse(BaseModel):
id: str
name: str
@@ -210,7 +220,7 @@ class UsersTable:
filter: Optional[dict] = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
- ) -> UserListResponse:
+ ) -> dict:
with get_db() as db:
query = db.query(User)
diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py
index 45f3d8c941..b3d90cc8f3 100644
--- a/backend/open_webui/retrieval/loaders/main.py
+++ b/backend/open_webui/retrieval/loaders/main.py
@@ -346,11 +346,9 @@ class Loader:
self.engine == "document_intelligence"
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
and (
- file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"]
+ file_ext in ["pdf", "docx", "ppt", "pptx"]
or file_content_type
in [
- "application/vnd.ms-excel",
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py
index f5db7521b5..65da1592e1 100644
--- a/backend/open_webui/retrieval/utils.py
+++ b/backend/open_webui/retrieval/utils.py
@@ -127,7 +127,13 @@ def query_doc_with_hybrid_search(
hybrid_bm25_weight: float,
) -> dict:
try:
- if not collection_result.documents[0]:
+ if (
+ not collection_result
+ or not hasattr(collection_result, "documents")
+ or not collection_result.documents
+ or len(collection_result.documents) == 0
+ or not collection_result.documents[0]
+ ):
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
return {"documents": [], "metadatas": [], "distances": []}
@@ -621,6 +627,7 @@ 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)
):
diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py
index 9675e141e7..1fdb064c51 100755
--- a/backend/open_webui/retrieval/vector/dbs/chroma.py
+++ b/backend/open_webui/retrieval/vector/dbs/chroma.py
@@ -11,7 +11,7 @@ from open_webui.retrieval.vector.main import (
SearchResult,
GetResult,
)
-from open_webui.retrieval.vector.utils import stringify_metadata
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.config import (
CHROMA_DATA_PATH,
@@ -146,7 +146,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
- metadatas = [stringify_metadata(item["metadata"]) for item in items]
+ metadatas = [process_metadata(item["metadata"]) for item in items]
for batch in create_batches(
api=self.client,
@@ -166,7 +166,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
- metadatas = [stringify_metadata(item["metadata"]) for item in items]
+ metadatas = [process_metadata(item["metadata"]) for item in items]
collection.upsert(
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py
index 727d831cff..6de0d859f8 100644
--- a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py
+++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py
@@ -3,7 +3,7 @@ from typing import Optional
import ssl
from elasticsearch.helpers import bulk, scan
-from open_webui.retrieval.vector.utils import stringify_metadata
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -245,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
- "metadata": stringify_metadata(item["metadata"]),
+ "metadata": process_metadata(item["metadata"]),
},
}
for item in batch
@@ -266,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
- "metadata": stringify_metadata(item["metadata"]),
+ "metadata": process_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py
index 059ea43cc0..98f8e335f2 100644
--- a/backend/open_webui/retrieval/vector/dbs/milvus.py
+++ b/backend/open_webui/retrieval/vector/dbs/milvus.py
@@ -6,7 +6,7 @@ import json
import logging
from typing import Optional
-from open_webui.retrieval.vector.utils import stringify_metadata
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -22,6 +22,8 @@ from open_webui.config import (
MILVUS_HNSW_M,
MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST,
+ MILVUS_DISKANN_MAX_DEGREE,
+ MILVUS_DISKANN_SEARCH_LIST_SIZE,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -131,12 +133,18 @@ class MilvusClient(VectorDBBase):
elif index_type == "IVF_FLAT":
index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST}
log.info(f"IVF_FLAT params: {index_creation_params}")
+ elif index_type == "DISKANN":
+ index_creation_params = {
+ "max_degree": MILVUS_DISKANN_MAX_DEGREE,
+ "search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE,
+ }
+ log.info(f"DISKANN params: {index_creation_params}")
elif index_type in ["FLAT", "AUTOINDEX"]:
log.info(f"Using {index_type} index with no specific build-time params.")
else:
log.warning(
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
- f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. "
+ f"Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. "
f"Milvus will use its default for the collection if this type is not directly supported for index creation."
)
# For unsupported types, pass the type directly to Milvus; it might handle it or use a default.
@@ -189,7 +197,7 @@ class MilvusClient(VectorDBBase):
)
return self._result_to_search_result(result)
- def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
+ def query(self, collection_name: str, filter: dict, limit: int = -1):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
# Construct the filter string for querying
@@ -222,7 +230,7 @@ class MilvusClient(VectorDBBase):
"data",
"metadata",
],
- limit=limit, # Pass the limit directly; None means no limit.
+ limit=limit, # Pass the limit directly; -1 means no limit.
)
while True:
@@ -249,7 +257,7 @@ class MilvusClient(VectorDBBase):
)
# Using query with a trivial filter to get all items.
# This will use the paginated query logic.
- return self.query(collection_name=collection_name, filter={}, limit=None)
+ return self.query(collection_name=collection_name, filter={}, limit=-1)
def insert(self, collection_name: str, items: list[VectorItem]):
# Insert the items into the collection, if the collection does not exist, it will be created.
@@ -281,7 +289,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
- "metadata": stringify_metadata(item["metadata"]),
+ "metadata": process_metadata(item["metadata"]),
}
for item in items
],
@@ -317,7 +325,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
- "metadata": stringify_metadata(item["metadata"]),
+ "metadata": process_metadata(item["metadata"]),
}
for item in items
],
diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py
new file mode 100644
index 0000000000..5c80d155d3
--- /dev/null
+++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py
@@ -0,0 +1,282 @@
+import logging
+from typing import Optional, Tuple, List, Dict, Any
+
+from open_webui.config import (
+ MILVUS_URI,
+ MILVUS_TOKEN,
+ MILVUS_DB,
+ MILVUS_COLLECTION_PREFIX,
+ MILVUS_INDEX_TYPE,
+ MILVUS_METRIC_TYPE,
+ MILVUS_HNSW_M,
+ MILVUS_HNSW_EFCONSTRUCTION,
+ MILVUS_IVF_FLAT_NLIST,
+)
+from open_webui.env import SRC_LOG_LEVELS
+from open_webui.retrieval.vector.main import (
+ GetResult,
+ SearchResult,
+ VectorDBBase,
+ VectorItem,
+)
+from pymilvus import (
+ connections,
+ utility,
+ Collection,
+ CollectionSchema,
+ FieldSchema,
+ DataType,
+)
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+RESOURCE_ID_FIELD = "resource_id"
+
+
+class MilvusClient(VectorDBBase):
+ def __init__(self):
+ # Milvus collection names can only contain numbers, letters, and underscores.
+ self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_")
+ connections.connect(
+ alias="default",
+ uri=MILVUS_URI,
+ token=MILVUS_TOKEN,
+ db_name=MILVUS_DB,
+ )
+
+ # Main collection types for multi-tenancy
+ self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
+ self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
+ self.FILE_COLLECTION = f"{self.collection_prefix}_files"
+ self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web_search"
+ self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash_based"
+ self.shared_collections = [
+ self.MEMORY_COLLECTION,
+ self.KNOWLEDGE_COLLECTION,
+ self.FILE_COLLECTION,
+ self.WEB_SEARCH_COLLECTION,
+ self.HASH_BASED_COLLECTION,
+ ]
+
+ def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
+ """
+ Maps the traditional collection name to multi-tenant collection and resource ID.
+
+ WARNING: This mapping relies on current Open WebUI naming conventions for
+ collection names. If Open WebUI changes how it generates collection names
+ (e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
+ formats), this mapping will break and route data to incorrect collections.
+ POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
+ DATA MAPPING INSIDE THE DATABASE.
+ """
+ resource_id = collection_name
+
+ if collection_name.startswith("user-memory-"):
+ return self.MEMORY_COLLECTION, resource_id
+ elif collection_name.startswith("file-"):
+ return self.FILE_COLLECTION, resource_id
+ elif collection_name.startswith("web-search-"):
+ return self.WEB_SEARCH_COLLECTION, resource_id
+ elif len(collection_name) == 63 and all(
+ c in "0123456789abcdef" for c in collection_name
+ ):
+ return self.HASH_BASED_COLLECTION, resource_id
+ else:
+ return self.KNOWLEDGE_COLLECTION, resource_id
+
+ def _create_shared_collection(self, mt_collection_name: str, dimension: int):
+ fields = [
+ FieldSchema(
+ name="id",
+ dtype=DataType.VARCHAR,
+ is_primary=True,
+ auto_id=False,
+ max_length=36,
+ ),
+ FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
+ FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
+ FieldSchema(name="metadata", dtype=DataType.JSON),
+ FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
+ ]
+ schema = CollectionSchema(fields, "Shared collection for multi-tenancy")
+ collection = Collection(mt_collection_name, schema)
+
+ index_params = {
+ "metric_type": MILVUS_METRIC_TYPE,
+ "index_type": MILVUS_INDEX_TYPE,
+ "params": {},
+ }
+ if MILVUS_INDEX_TYPE == "HNSW":
+ index_params["params"] = {
+ "M": MILVUS_HNSW_M,
+ "efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
+ }
+ elif MILVUS_INDEX_TYPE == "IVF_FLAT":
+ index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST}
+
+ collection.create_index("vector", index_params)
+ collection.create_index(RESOURCE_ID_FIELD)
+ log.info(f"Created shared collection: {mt_collection_name}")
+ return collection
+
+ def _ensure_collection(self, mt_collection_name: str, dimension: int):
+ if not utility.has_collection(mt_collection_name):
+ self._create_shared_collection(mt_collection_name, dimension)
+
+ def has_collection(self, collection_name: str) -> bool:
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return False
+
+ collection = Collection(mt_collection)
+ collection.load()
+ res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
+ return len(res) > 0
+
+ def upsert(self, collection_name: str, items: List[VectorItem]):
+ if not items:
+ return
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ dimension = len(items[0]["vector"])
+ self._ensure_collection(mt_collection, dimension)
+ collection = Collection(mt_collection)
+
+ entities = [
+ {
+ "id": item["id"],
+ "vector": item["vector"],
+ "text": item["text"],
+ "metadata": item["metadata"],
+ RESOURCE_ID_FIELD: resource_id,
+ }
+ for item in items
+ ]
+ collection.insert(entities)
+ collection.flush()
+
+ def search(
+ self, collection_name: str, vectors: List[List[float]], limit: int
+ ) -> Optional[SearchResult]:
+ if not vectors:
+ return None
+
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return None
+
+ collection = Collection(mt_collection)
+ collection.load()
+
+ search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}}
+ results = collection.search(
+ data=vectors,
+ anns_field="vector",
+ param=search_params,
+ limit=limit,
+ expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
+ output_fields=["id", "text", "metadata"],
+ )
+
+ ids, documents, metadatas, distances = [], [], [], []
+ for hits in results:
+ batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
+ for hit in hits:
+ batch_ids.append(hit.entity.get("id"))
+ batch_docs.append(hit.entity.get("text"))
+ batch_metadatas.append(hit.entity.get("metadata"))
+ batch_dists.append(hit.distance)
+ ids.append(batch_ids)
+ documents.append(batch_docs)
+ metadatas.append(batch_metadatas)
+ distances.append(batch_dists)
+
+ return SearchResult(
+ ids=ids, documents=documents, metadatas=metadatas, distances=distances
+ )
+
+ def delete(
+ self,
+ collection_name: str,
+ ids: Optional[List[str]] = None,
+ filter: Optional[Dict[str, Any]] = None,
+ ):
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return
+
+ collection = Collection(mt_collection)
+
+ # Build expression
+ expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
+ if ids:
+ # Milvus expects a string list for 'in' operator
+ id_list_str = ", ".join([f"'{id_val}'" for id_val in ids])
+ expr.append(f"id in [{id_list_str}]")
+
+ if filter:
+ for key, value in filter.items():
+ expr.append(f"metadata['{key}'] == '{value}'")
+
+ collection.delete(" and ".join(expr))
+
+ def reset(self):
+ for collection_name in self.shared_collections:
+ if utility.has_collection(collection_name):
+ utility.drop_collection(collection_name)
+
+ def delete_collection(self, collection_name: str):
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return
+
+ collection = Collection(mt_collection)
+ collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
+
+ def query(
+ self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
+ ) -> Optional[GetResult]:
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return None
+
+ collection = Collection(mt_collection)
+ collection.load()
+
+ expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
+ if filter:
+ for key, value in filter.items():
+ if isinstance(value, str):
+ expr.append(f"metadata['{key}'] == '{value}'")
+ else:
+ expr.append(f"metadata['{key}'] == {value}")
+
+ results = collection.query(
+ expr=" and ".join(expr),
+ output_fields=["id", "text", "metadata"],
+ limit=limit,
+ )
+
+ ids = [res["id"] for res in results]
+ documents = [res["text"] for res in results]
+ metadatas = [res["metadata"] for res in results]
+
+ return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
+
+ def get(self, collection_name: str) -> Optional[GetResult]:
+ return self.query(collection_name, filter={}, limit=None)
+
+ def insert(self, collection_name: str, items: List[VectorItem]):
+ return self.upsert(collection_name, items)
diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py
index 510070f97a..2e946710e2 100644
--- a/backend/open_webui/retrieval/vector/dbs/opensearch.py
+++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py
@@ -2,7 +2,7 @@ from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
-from open_webui.retrieval.vector.utils import stringify_metadata
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -201,7 +201,7 @@ class OpenSearchClient(VectorDBBase):
"_source": {
"vector": item["vector"],
"text": item["text"],
- "metadata": stringify_metadata(item["metadata"]),
+ "metadata": process_metadata(item["metadata"]),
},
}
for item in batch
@@ -223,7 +223,7 @@ class OpenSearchClient(VectorDBBase):
"doc": {
"vector": item["vector"],
"text": item["text"],
- "metadata": stringify_metadata(item["metadata"]),
+ "metadata": process_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py
index 06c1698cdd..312b48944c 100644
--- a/backend/open_webui/retrieval/vector/dbs/pgvector.py
+++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py
@@ -27,7 +27,7 @@ from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
-from open_webui.retrieval.vector.utils import stringify_metadata
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -265,7 +265,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
- vmetadata=stringify_metadata(item["metadata"]),
+ vmetadata=process_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
@@ -323,7 +323,7 @@ class PgvectorClient(VectorDBBase):
if existing:
existing.vector = vector
existing.text = item["text"]
- existing.vmetadata = stringify_metadata(item["metadata"])
+ existing.vmetadata = process_metadata(item["metadata"])
existing.collection_name = (
collection_name # Update collection_name if necessary
)
@@ -333,7 +333,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
- vmetadata=stringify_metadata(item["metadata"]),
+ vmetadata=process_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()
diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py
index 466b5a6e24..5bef0d9ea7 100644
--- a/backend/open_webui/retrieval/vector/dbs/pinecone.py
+++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py
@@ -32,7 +32,7 @@ from open_webui.config import (
PINECONE_CLOUD,
)
from open_webui.env import SRC_LOG_LEVELS
-from open_webui.retrieval.vector.utils import stringify_metadata
+from open_webui.retrieval.vector.utils import process_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
@@ -185,7 +185,7 @@ class PineconeClient(VectorDBBase):
point = {
"id": item["id"],
"values": item["vector"],
- "metadata": stringify_metadata(metadata),
+ "metadata": process_metadata(metadata),
}
points.append(point)
return points
diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py
index ed4a8bab34..e9fa03d459 100644
--- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py
+++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py
@@ -105,6 +105,13 @@ class QdrantClient(VectorDBBase):
Returns:
tuple: (collection_name, tenant_id)
+
+ WARNING: This mapping relies on current Open WebUI naming conventions for
+ collection names. If Open WebUI changes how it generates collection names
+ (e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
+ formats), this mapping will break and route data to incorrect collections.
+ POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
+ DATA MAPPING INSIDE THE DATABASE.
"""
# Check for user memory collections
tenant_id = collection_name
diff --git a/backend/open_webui/retrieval/vector/dbs/s3vector.py b/backend/open_webui/retrieval/vector/dbs/s3vector.py
index 2ac6911769..519ee5abad 100644
--- a/backend/open_webui/retrieval/vector/dbs/s3vector.py
+++ b/backend/open_webui/retrieval/vector/dbs/s3vector.py
@@ -1,4 +1,4 @@
-from open_webui.retrieval.vector.utils import stringify_metadata
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -185,7 +185,7 @@ class S3VectorClient(VectorDBBase):
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
- metadata = stringify_metadata(metadata)
+ metadata = process_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
@@ -256,7 +256,7 @@ class S3VectorClient(VectorDBBase):
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
- metadata = stringify_metadata(metadata)
+ metadata = process_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
diff --git a/backend/open_webui/retrieval/vector/factory.py b/backend/open_webui/retrieval/vector/factory.py
index 36cb85c948..7888c22be8 100644
--- a/backend/open_webui/retrieval/vector/factory.py
+++ b/backend/open_webui/retrieval/vector/factory.py
@@ -1,6 +1,10 @@
from open_webui.retrieval.vector.main import VectorDBBase
from open_webui.retrieval.vector.type import VectorType
-from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE
+from open_webui.config import (
+ VECTOR_DB,
+ ENABLE_QDRANT_MULTITENANCY_MODE,
+ ENABLE_MILVUS_MULTITENANCY_MODE,
+)
class Vector:
@@ -12,9 +16,16 @@ class Vector:
"""
match vector_type:
case VectorType.MILVUS:
- from open_webui.retrieval.vector.dbs.milvus import MilvusClient
+ if ENABLE_MILVUS_MULTITENANCY_MODE:
+ from open_webui.retrieval.vector.dbs.milvus_multitenancy import (
+ MilvusClient,
+ )
- return MilvusClient()
+ return MilvusClient()
+ else:
+ from open_webui.retrieval.vector.dbs.milvus import MilvusClient
+
+ return MilvusClient()
case VectorType.QDRANT:
if ENABLE_QDRANT_MULTITENANCY_MODE:
from open_webui.retrieval.vector.dbs.qdrant_multitenancy import (
diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py
index 1d9698c6b1..a597390b92 100644
--- a/backend/open_webui/retrieval/vector/utils.py
+++ b/backend/open_webui/retrieval/vector/utils.py
@@ -1,10 +1,24 @@
from datetime import datetime
+KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"]
-def stringify_metadata(
+
+def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
+ metadata = {
+ key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
+ }
+ return metadata
+
+
+def process_metadata(
metadata: dict[str, any],
) -> dict[str, any]:
for key, value in metadata.items():
+ # Remove large fields
+ if key in KEYS_TO_EXCLUDE:
+ del metadata[key]
+
+ # Convert non-serializable fields to strings
if (
isinstance(value, datetime)
or isinstance(value, list)
diff --git a/backend/open_webui/retrieval/web/ollama.py b/backend/open_webui/retrieval/web/ollama.py
new file mode 100644
index 0000000000..a199a14389
--- /dev/null
+++ b/backend/open_webui/retrieval/web/ollama.py
@@ -0,0 +1,51 @@
+import logging
+from dataclasses import dataclass
+from typing import Optional
+
+import requests
+from open_webui.env import SRC_LOG_LEVELS
+from open_webui.retrieval.web.main import SearchResult
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+def search_ollama_cloud(
+ url: str,
+ api_key: str,
+ query: str,
+ count: int,
+ filter_list: Optional[list[str]] = None,
+) -> list[SearchResult]:
+ """Search using Ollama Search API and return the results as a list of SearchResult objects.
+
+ Args:
+ api_key (str): A Ollama Search API key
+ query (str): The query to search for
+ count (int): Number of results to return
+ filter_list (Optional[list[str]]): List of domains to filter results by
+ """
+ log.info(f"Searching with Ollama for query: {query}")
+
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+ payload = {"query": query, "max_results": count}
+
+ try:
+ response = requests.post(f"{url}/api/web_search", headers=headers, json=payload)
+ response.raise_for_status()
+ data = response.json()
+
+ results = data.get("results", [])
+ log.info(f"Found {len(results)} results")
+
+ return [
+ SearchResult(
+ link=result.get("url", ""),
+ title=result.get("title", ""),
+ snippet=result.get("content", ""),
+ )
+ for result in results
+ ]
+ except Exception as e:
+ log.error(f"Error searching Ollama: {e}")
+ return []
diff --git a/backend/open_webui/retrieval/web/perplexity_search.py b/backend/open_webui/retrieval/web/perplexity_search.py
new file mode 100644
index 0000000000..e3e0caa2b3
--- /dev/null
+++ b/backend/open_webui/retrieval/web/perplexity_search.py
@@ -0,0 +1,64 @@
+import logging
+from typing import Optional, Literal
+import requests
+
+from open_webui.retrieval.web.main import SearchResult, get_filtered_results
+from open_webui.env import SRC_LOG_LEVELS
+
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+def search_perplexity_search(
+ api_key: str,
+ query: str,
+ count: int,
+ filter_list: Optional[list[str]] = None,
+) -> list[SearchResult]:
+ """Search using Perplexity API and return the results as a list of SearchResult objects.
+
+ Args:
+ api_key (str): A Perplexity API key
+ query (str): The query to search for
+ count (int): Maximum number of results to return
+ filter_list (Optional[list[str]]): List of domains to filter results
+
+ """
+
+ # Handle PersistentConfig object
+ if hasattr(api_key, "__str__"):
+ api_key = str(api_key)
+
+ try:
+ url = "https://api.perplexity.ai/search"
+
+ # Create payload for the API call
+ payload = {
+ "query": query,
+ "max_results": count,
+ }
+
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+
+ # Make the API request
+ response = requests.request("POST", url, json=payload, headers=headers)
+ # Parse the JSON response
+ json_response = response.json()
+
+ # Extract citations from the response
+ results = json_response.get("results", [])
+
+ return [
+ SearchResult(
+ link=result["url"], title=result["title"], snippet=result["snippet"]
+ )
+ for result in results
+ ]
+
+ except Exception as e:
+ log.error(f"Error searching with Perplexity Search API: {e}")
+ return []
diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py
index c4a187b50d..cb7a57b5b7 100644
--- a/backend/open_webui/routers/audio.py
+++ b/backend/open_webui/routers/audio.py
@@ -3,6 +3,7 @@ import json
import logging
import os
import uuid
+import html
from functools import lru_cache
from pydub import AudioSegment
from pydub.silence import split_on_silence
@@ -153,6 +154,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False):
class TTSConfigForm(BaseModel):
OPENAI_API_BASE_URL: str
OPENAI_API_KEY: str
+ OPENAI_PARAMS: Optional[dict] = None
API_KEY: str
ENGINE: str
MODEL: str
@@ -189,6 +191,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
"tts": {
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
+ "OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
"API_KEY": request.app.state.config.TTS_API_KEY,
"ENGINE": request.app.state.config.TTS_ENGINE,
"MODEL": request.app.state.config.TTS_MODEL,
@@ -221,6 +224,7 @@ async def update_audio_config(
):
request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL
request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY
+ request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS
request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY
request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE
request.app.state.config.TTS_MODEL = form_data.tts.MODEL
@@ -261,12 +265,13 @@ async def update_audio_config(
return {
"tts": {
- "OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
- "OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
- "API_KEY": request.app.state.config.TTS_API_KEY,
"ENGINE": request.app.state.config.TTS_ENGINE,
"MODEL": request.app.state.config.TTS_MODEL,
"VOICE": request.app.state.config.TTS_VOICE,
+ "OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
+ "OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
+ "OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
+ "API_KEY": request.app.state.config.TTS_API_KEY,
"SPLIT_ON": request.app.state.config.TTS_SPLIT_ON,
"AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION,
"AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
@@ -336,6 +341,11 @@ async def speech(request: Request, user=Depends(get_verified_user)):
async with aiohttp.ClientSession(
timeout=timeout, trust_env=True
) as session:
+ payload = {
+ **payload,
+ **(request.app.state.config.TTS_OPENAI_PARAMS or {}),
+ }
+
r = await session.post(
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
json=payload,
@@ -458,7 +468,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
try:
data = f"""
", "\n"),
- metadata={
- **file.meta,
- "name": file.filename,
- "created_by": file.user_id,
- "file_id": file.id,
- "source": file.filename,
- },
+ try:
+ # /files/{file_id}/data/content/update
+ VECTOR_DB_CLIENT.delete_collection(
+ collection_name=f"file-{file.id}"
+ )
+ except:
+ # Audio file upload pipeline
+ pass
+
+ docs = [
+ Document(
+ page_content=form_data.content.replace("
", "\n"),
+ metadata={
+ **file.meta,
+ "name": file.filename,
+ "created_by": file.user_id,
+ "file_id": file.id,
+ "source": file.filename,
+ },
+ )
+ ]
+
+ text_content = form_data.content
+ elif form_data.collection_name:
+ # Check if the file has already been processed and save the content
+ # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
+
+ result = VECTOR_DB_CLIENT.query(
+ collection_name=f"file-{file.id}", filter={"file_id": file.id}
)
- ]
- text_content = form_data.content
- elif form_data.collection_name:
- # Check if the file has already been processed and save the content
- # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
+ if result is not None and len(result.ids[0]) > 0:
+ docs = [
+ Document(
+ page_content=result.documents[0][idx],
+ metadata=result.metadatas[0][idx],
+ )
+ for idx, id in enumerate(result.ids[0])
+ ]
+ else:
+ docs = [
+ Document(
+ page_content=file.data.get("content", ""),
+ metadata={
+ **file.meta,
+ "name": file.filename,
+ "created_by": file.user_id,
+ "file_id": file.id,
+ "source": file.filename,
+ },
+ )
+ ]
- result = VECTOR_DB_CLIENT.query(
- collection_name=f"file-{file.id}", filter={"file_id": file.id}
+ text_content = file.data.get("content", "")
+ else:
+ # Process the file and save the content
+ # Usage: /files/
+ file_path = file.path
+ if file_path:
+ file_path = Storage.get_file(file_path)
+ loader = Loader(
+ engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
+ 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,
+ DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
+ DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
+ DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
+ DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
+ DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
+ DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
+ DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
+ DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
+ EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
+ EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
+ TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
+ DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
+ DOCLING_PARAMS={
+ "do_ocr": request.app.state.config.DOCLING_DO_OCR,
+ "force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
+ "ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
+ "ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
+ "pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
+ "table_mode": request.app.state.config.DOCLING_TABLE_MODE,
+ "pipeline": request.app.state.config.DOCLING_PIPELINE,
+ "do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
+ "picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
+ "picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
+ "picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
+ **request.app.state.config.DOCLING_PARAMS,
+ },
+ PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
+ DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
+ DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
+ MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
+ )
+ docs = loader.load(
+ file.filename, file.meta.get("content_type"), file_path
+ )
+
+ docs = [
+ Document(
+ page_content=doc.page_content,
+ metadata={
+ **filter_metadata(doc.metadata),
+ "name": file.filename,
+ "created_by": file.user_id,
+ "file_id": file.id,
+ "source": file.filename,
+ },
+ )
+ for doc in docs
+ ]
+ else:
+ docs = [
+ Document(
+ page_content=file.data.get("content", ""),
+ metadata={
+ **file.meta,
+ "name": file.filename,
+ "created_by": file.user_id,
+ "file_id": file.id,
+ "source": file.filename,
+ },
+ )
+ ]
+ text_content = " ".join([doc.page_content for doc in docs])
+
+ log.debug(f"text_content: {text_content}")
+ Files.update_file_data_by_id(
+ file.id,
+ {"content": text_content},
)
+ hash = calculate_sha256_string(text_content)
+ Files.update_file_hash_by_id(file.id, hash)
- if result is not None and len(result.ids[0]) > 0:
- docs = [
- Document(
- page_content=result.documents[0][idx],
- metadata=result.metadatas[0][idx],
- )
- for idx, id in enumerate(result.ids[0])
- ]
+ if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
+ Files.update_file_data_by_id(file.id, {"status": "completed"})
+ return {
+ "status": True,
+ "collection_name": None,
+ "filename": file.filename,
+ "content": text_content,
+ }
else:
- docs = [
- Document(
- page_content=file.data.get("content", ""),
+ try:
+ result = save_docs_to_vector_db(
+ request,
+ docs=docs,
+ collection_name=collection_name,
metadata={
- **file.meta,
- "name": file.filename,
- "created_by": file.user_id,
"file_id": file.id,
- "source": file.filename,
- },
- )
- ]
-
- text_content = file.data.get("content", "")
- else:
- # Process the file and save the content
- # Usage: /files/
- file_path = file.path
- if file_path:
- file_path = Storage.get_file(file_path)
- loader = Loader(
- engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
- 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,
- DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
- DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
- DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
- DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
- DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
- DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
- DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
- DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
- EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
- EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
- TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
- DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
- DOCLING_PARAMS={
- "do_ocr": request.app.state.config.DOCLING_DO_OCR,
- "force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
- "ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
- "ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
- "pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
- "table_mode": request.app.state.config.DOCLING_TABLE_MODE,
- "pipeline": request.app.state.config.DOCLING_PIPELINE,
- "do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
- "picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
- "picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
- "picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
- },
- PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
- DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
- DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
- MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
- )
- docs = loader.load(
- file.filename, file.meta.get("content_type"), file_path
- )
-
- docs = [
- Document(
- page_content=doc.page_content,
- metadata={
- **doc.metadata,
"name": file.filename,
- "created_by": file.user_id,
- "file_id": file.id,
- "source": file.filename,
+ "hash": hash,
},
+ add=(True if form_data.collection_name else False),
+ user=user,
)
- for doc in docs
- ]
- else:
- docs = [
- Document(
- page_content=file.data.get("content", ""),
- metadata={
- **file.meta,
- "name": file.filename,
- "created_by": file.user_id,
- "file_id": file.id,
- "source": file.filename,
- },
- )
- ]
- text_content = " ".join([doc.page_content for doc in docs])
+ log.info(f"added {len(docs)} items to collection {collection_name}")
- log.debug(f"text_content: {text_content}")
- Files.update_file_data_by_id(
- file.id,
- {"content": text_content},
- )
- hash = calculate_sha256_string(text_content)
- Files.update_file_hash_by_id(file.id, hash)
+ if result:
+ Files.update_file_metadata_by_id(
+ file.id,
+ {
+ "collection_name": collection_name,
+ },
+ )
- if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
- Files.update_file_data_by_id(file.id, {"status": "completed"})
- return {
- "status": True,
- "collection_name": None,
- "filename": file.filename,
- "content": text_content,
- }
- else:
- try:
- result = save_docs_to_vector_db(
- request,
- docs=docs,
- collection_name=collection_name,
- metadata={
- "file_id": file.id,
- "name": file.filename,
- "hash": hash,
- },
- add=(True if form_data.collection_name else False),
- user=user,
- )
- log.info(f"added {len(docs)} items to collection {collection_name}")
+ Files.update_file_data_by_id(
+ file.id,
+ {"status": "completed"},
+ )
- if result:
- Files.update_file_metadata_by_id(
- file.id,
- {
+ return {
+ "status": True,
"collection_name": collection_name,
- },
- )
+ "filename": file.filename,
+ "content": text_content,
+ }
+ else:
+ raise Exception("Error saving document to vector database")
+ except Exception as e:
+ raise e
- return {
- "status": True,
- "collection_name": collection_name,
- "filename": file.filename,
- "content": text_content,
- }
- except Exception as e:
- raise e
+ except Exception as e:
+ log.exception(e)
+ Files.update_file_data_by_id(
+ file.id,
+ {"status": "failed"},
+ )
- except Exception as e:
- log.exception(e)
- if "No pandoc was found" in str(e):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
- )
- else:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=str(e),
- )
+ if "No pandoc was found" in str(e):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
+ )
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(e),
+ )
+
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+ )
class ProcessTextForm(BaseModel):
@@ -1825,7 +1869,25 @@ async def search_web(request: Request, engine: str, query: str) -> list[SearchRe
logging.info(f"search_web: {engine} query: {query}")
# TODO: add playwright to search the web
- if engine == "searxng":
+ if engine == "ollama_cloud":
+ return search_ollama_cloud(
+ "https://ollama.com",
+ request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
+ query,
+ request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+ request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+ )
+ elif engine == "perplexity_search":
+ if request.app.state.config.PERPLEXITY_API_KEY:
+ return search_perplexity_search(
+ request.app.state.config.PERPLEXITY_API_KEY,
+ query,
+ request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+ request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+ )
+ else:
+ raise Exception("No PERPLEXITY_API_KEY found in environment variables")
+ elif engine == "searxng":
if request.app.state.config.SEARXNG_QUERY_URL:
return search_searxng(
request.app.state.config.SEARXNG_QUERY_URL,
@@ -2059,7 +2121,7 @@ async def process_web_search(
result_items = []
try:
- logging.info(
+ logging.debug(
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
)
diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py
index 5f82e7f1bd..2fa3f6abf6 100644
--- a/backend/open_webui/routers/tools.py
+++ b/backend/open_webui/routers/tools.py
@@ -9,6 +9,7 @@ from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status
+from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.tools import (
ToolForm,
ToolModel,
@@ -16,7 +17,11 @@ from open_webui.models.tools import (
ToolUserResponse,
Tools,
)
-from open_webui.utils.plugin import load_tool_module_by_id, replace_imports
+from open_webui.utils.plugin import (
+ load_tool_module_by_id,
+ replace_imports,
+ get_tool_module_from_cache,
+)
from open_webui.utils.tools import get_tool_specs
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
@@ -34,6 +39,14 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
+def get_tool_module(request, tool_id, load_from_db=True):
+ """
+ Get the tool module by its ID.
+ """
+ tool_module, _ = get_tool_module_from_cache(request, tool_id, load_from_db)
+ return tool_module
+
+
############################
# GetTools
############################
@@ -41,8 +54,21 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)):
- tools = Tools.get_tools()
+ tools = []
+ # Local Tools
+ for tool in Tools.get_tools():
+ tool_module = get_tool_module(request, tool.id)
+ tools.append(
+ ToolUserResponse(
+ **{
+ **tool.model_dump(),
+ "has_user_valves": hasattr(tool_module, "UserValves"),
+ }
+ )
+ )
+
+ # OpenAPI Tool Servers
for server in await get_tool_servers(request):
tools.append(
ToolUserResponse(
@@ -68,6 +94,50 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
)
)
+ # MCP Tool Servers
+ for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
+ if server.get("type", "openapi") == "mcp":
+ server_id = server.get("info", {}).get("id")
+ auth_type = server.get("auth_type", "none")
+
+ session_token = None
+ if auth_type == "oauth_2.1":
+ splits = server_id.split(":")
+ server_id = splits[-1] if len(splits) > 1 else server_id
+
+ session_token = (
+ await request.app.state.oauth_client_manager.get_oauth_token(
+ user.id, f"mcp:{server_id}"
+ )
+ )
+
+ tools.append(
+ ToolUserResponse(
+ **{
+ "id": f"server:mcp:{server.get('info', {}).get('id')}",
+ "user_id": f"server:mcp:{server.get('info', {}).get('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()),
+ **(
+ {
+ "authenticated": session_token is not None,
+ }
+ if auth_type == "oauth_2.1"
+ else {}
+ ),
+ }
+ )
+ )
+
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
# Admin can see all tools
return tools
@@ -462,8 +532,9 @@ async def update_tools_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data)
- Tools.update_tool_valves_by_id(id, valves.model_dump())
- return valves.model_dump()
+ valves_dict = valves.model_dump(exclude_unset=True)
+ Tools.update_tool_valves_by_id(id, valves_dict)
+ return valves_dict
except Exception as e:
log.exception(f"Failed to update tool valves by id {id}: {e}")
raise HTTPException(
@@ -538,10 +609,11 @@ async def update_tools_user_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data)
+ user_valves_dict = user_valves.model_dump(exclude_unset=True)
Tools.update_user_valves_by_id_and_user_id(
- id, user.id, user_valves.model_dump()
+ id, user.id, user_valves_dict
)
- return user_valves.model_dump()
+ return user_valves_dict
except Exception as e:
log.exception(f"Failed to update user valves by id {id}: {e}")
raise HTTPException(
diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py
index 5b331dce73..2dd229eeb7 100644
--- a/backend/open_webui/routers/users.py
+++ b/backend/open_webui/routers/users.py
@@ -18,6 +18,7 @@ from open_webui.models.users import (
UserModel,
UserListResponse,
UserInfoListResponse,
+ UserIdNameListResponse,
UserRoleUpdateForm,
Users,
UserSettings,
@@ -100,6 +101,23 @@ async def get_all_users(
return Users.get_users()
+@router.get("/search", response_model=UserIdNameListResponse)
+async def search_users(
+ query: Optional[str] = None,
+ user=Depends(get_verified_user),
+):
+ limit = PAGE_ITEM_COUNT
+
+ page = 1 # Always return the first page for search
+ skip = (page - 1) * limit
+
+ filter = {}
+ if query:
+ filter["query"] = query
+
+ return Users.get_users(filter=filter, skip=skip, limit=limit)
+
+
############################
# User Groups
############################
@@ -139,6 +157,7 @@ class SharingPermissions(BaseModel):
public_knowledge: bool = True
public_prompts: bool = True
public_tools: bool = True
+ public_notes: bool = True
class ChatPermissions(BaseModel):
diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py
index b64eab08ac..47b2c57961 100644
--- a/backend/open_webui/socket/main.py
+++ b/backend/open_webui/socket/main.py
@@ -356,7 +356,7 @@ async def join_note(sid, data):
await sio.enter_room(sid, f"note:{note.id}")
-@sio.on("channel-events")
+@sio.on("events:channel")
async def channel_events(sid, data):
room = f"channel:{data['channel_id']}"
participants = sio.manager.get_participants(
@@ -373,7 +373,7 @@ async def channel_events(sid, data):
if event_type == "typing":
await sio.emit(
- "channel-events",
+ "events:channel",
{
"channel_id": data["channel_id"],
"message_id": data.get("message_id", None),
@@ -653,12 +653,15 @@ def get_event_emitter(request_info, update_db=True):
)
)
+ chat_id = request_info.get("chat_id", None)
+ message_id = request_info.get("message_id", None)
+
emit_tasks = [
sio.emit(
- "chat-events",
+ "events",
{
- "chat_id": request_info.get("chat_id", None),
- "message_id": request_info.get("message_id", None),
+ "chat_id": chat_id,
+ "message_id": message_id,
"data": event_data,
},
to=session_id,
@@ -667,8 +670,11 @@ def get_event_emitter(request_info, update_db=True):
]
await asyncio.gather(*emit_tasks)
-
- if update_db:
+ if (
+ update_db
+ and message_id
+ and not request_info.get("chat_id", "").startswith("local:")
+ ):
if "type" in event_data and event_data["type"] == "status":
Chats.add_message_status_to_chat_by_id_and_message_id(
request_info["chat_id"],
@@ -705,6 +711,23 @@ def get_event_emitter(request_info, update_db=True):
},
)
+ if "type" in event_data and event_data["type"] == "embeds":
+ message = Chats.get_message_by_id_and_message_id(
+ request_info["chat_id"],
+ request_info["message_id"],
+ )
+
+ embeds = event_data.get("data", {}).get("embeds", [])
+ embeds.extend(message.get("embeds", []))
+
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ request_info["chat_id"],
+ request_info["message_id"],
+ {
+ "embeds": embeds,
+ },
+ )
+
if "type" in event_data and event_data["type"] == "files":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
@@ -747,7 +770,7 @@ def get_event_emitter(request_info, update_db=True):
def get_event_call(request_info):
async def __event_caller__(event_data):
response = await sio.call(
- "chat-events",
+ "events",
{
"chat_id": request_info.get("chat_id", None),
"message_id": request_info.get("message_id", None),
diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py
index a15e8ac146..3e31438281 100644
--- a/backend/open_webui/tasks.py
+++ b/backend/open_webui/tasks.py
@@ -164,7 +164,10 @@ async def stop_task(redis, task_id: str):
# Task successfully canceled
return {"status": True, "message": f"Task {task_id} successfully stopped."}
- return {"status": False, "message": f"Failed to stop task {task_id}."}
+ if task.cancelled() or task.done():
+ return {"status": True, "message": f"Task {task_id} successfully cancelled."}
+
+ return {"status": True, "message": f"Cancellation requested for {task_id}."}
async def stop_item_tasks(redis: Redis, item_id: str):
diff --git a/backend/open_webui/utils/access_control.py b/backend/open_webui/utils/access_control.py
index 1529773c44..af48bebfb4 100644
--- a/backend/open_webui/utils/access_control.py
+++ b/backend/open_webui/utils/access_control.py
@@ -110,9 +110,13 @@ def has_access(
type: str = "write",
access_control: Optional[dict] = None,
user_group_ids: Optional[Set[str]] = None,
+ strict: bool = True,
) -> bool:
if access_control is None:
- return type == "read"
+ if strict:
+ return type == "read"
+ else:
+ return True
if user_group_ids is None:
user_groups = Groups.get_groups_by_member_id(user_id)
@@ -130,9 +134,10 @@ def has_access(
# Get all users with access to a resource
def get_users_with_access(
type: str = "write", access_control: Optional[dict] = None
-) -> List[UserModel]:
+) -> list[UserModel]:
if access_control is None:
- return Users.get_users()
+ result = Users.get_users()
+ return result.get("users", [])
permission_access = access_control.get(type, {})
permitted_group_ids = permission_access.get("group_ids", [])
diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py
index f941ef9263..e34803ade1 100644
--- a/backend/open_webui/utils/auth.py
+++ b/backend/open_webui/utils/auth.py
@@ -6,7 +6,7 @@ import hmac
import hashlib
import requests
import os
-
+import bcrypt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import ed25519
@@ -38,11 +38,8 @@ from open_webui.env import (
from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
-from passlib.context import CryptContext
-logging.getLogger("passlib").setLevel(logging.ERROR)
-
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OAUTH"])
@@ -155,19 +152,25 @@ def get_license_data(app, key):
bearer_security = HTTPBearer(auto_error=False)
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
-def verify_password(plain_password, hashed_password):
+def get_password_hash(password: str) -> str:
+ """Hash a password using bcrypt"""
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ """Verify a password against its hash"""
return (
- pwd_context.verify(plain_password, hashed_password) if hashed_password else None
+ bcrypt.checkpw(
+ plain_password.encode("utf-8"),
+ hashed_password.encode("utf-8"),
+ )
+ if hashed_password
+ else None
)
-def get_password_hash(password):
- return pwd_context.hash(password)
-
-
def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str:
payload = data.copy()
diff --git a/backend/open_webui/utils/channels.py b/backend/open_webui/utils/channels.py
new file mode 100644
index 0000000000..312b5ea24c
--- /dev/null
+++ b/backend/open_webui/utils/channels.py
@@ -0,0 +1,31 @@
+import re
+
+
+def extract_mentions(message: str, triggerChar: str = "@"):
+ # Escape triggerChar in case it's a regex special character
+ triggerChar = re.escape(triggerChar)
+ pattern = rf"<{triggerChar}([A-Z]):([^|>]+)"
+
+ matches = re.findall(pattern, message)
+ return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches]
+
+
+def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True):
+ """
+ Replace mentions in the message with either their label (after the pipe `|`)
+ or their id if no label exists.
+
+ Example:
+ "<@M:gpt-4.1|GPT-4>" -> "GPT-4" (if use_label=True)
+ "<@M:gpt-4.1|GPT-4>" -> "gpt-4.1" (if use_label=False)
+ """
+ # Escape triggerChar
+ triggerChar = re.escape(triggerChar)
+
+ def replacer(match):
+ id_type, id_value, label = match.groups()
+ return label if use_label and label else id_value
+
+ # Regex captures: idType, id, optional label
+ pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>"
+ return re.sub(pattern, replacer, message)
diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py
index 83483f391b..8b6a0b9da2 100644
--- a/backend/open_webui/utils/chat.py
+++ b/backend/open_webui/utils/chat.py
@@ -80,6 +80,7 @@ async def generate_direct_chat_completion(
event_caller = get_event_call(metadata)
channel = f"{user_id}:{session_id}:{request_id}"
+ logging.info(f"WebSocket channel: {channel}")
if form_data.get("stream"):
q = asyncio.Queue()
@@ -121,7 +122,10 @@ async def generate_direct_chat_completion(
yield f"data: {json.dumps(data)}\n\n"
elif isinstance(data, str):
- yield data
+ if "data:" in data:
+ yield f"{data}\n\n"
+ else:
+ yield f"data: {data}\n\n"
except Exception as e:
log.debug(f"Error in event generator: {e}")
pass
diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py
new file mode 100644
index 0000000000..b410cbab50
--- /dev/null
+++ b/backend/open_webui/utils/files.py
@@ -0,0 +1,97 @@
+from open_webui.routers.images import (
+ load_b64_image_data,
+ upload_image,
+)
+
+from fastapi import (
+ APIRouter,
+ Depends,
+ HTTPException,
+ Request,
+ UploadFile,
+)
+
+from open_webui.routers.files import upload_file_handler
+
+import mimetypes
+import base64
+import io
+
+
+def get_image_url_from_base64(request, base64_image_string, metadata, user):
+ if "data:image/png;base64" in base64_image_string:
+ image_url = ""
+ # Extract base64 image data from the line
+ image_data, content_type = load_b64_image_data(base64_image_string)
+ if image_data is not None:
+ image_url = upload_image(
+ request,
+ image_data,
+ content_type,
+ metadata,
+ user,
+ )
+ return image_url
+ return None
+
+
+def load_b64_audio_data(b64_str):
+ try:
+ if "," in b64_str:
+ header, b64_data = b64_str.split(",", 1)
+ else:
+ b64_data = b64_str
+ header = "data:audio/wav;base64"
+ audio_data = base64.b64decode(b64_data)
+ content_type = (
+ header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
+ )
+ return audio_data, content_type
+ except Exception as e:
+ print(f"Error decoding base64 audio data: {e}")
+ return None, None
+
+
+def upload_audio(request, audio_data, content_type, metadata, user):
+ audio_format = mimetypes.guess_extension(content_type)
+ file = UploadFile(
+ file=io.BytesIO(audio_data),
+ filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
+ headers={
+ "content-type": content_type,
+ },
+ )
+ file_item = upload_file_handler(
+ request,
+ file=file,
+ metadata=metadata,
+ process=False,
+ user=user,
+ )
+ url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
+ return url
+
+
+def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
+ if "data:audio/wav;base64" in base64_audio_string:
+ audio_url = ""
+ # Extract base64 audio data from the line
+ audio_data, content_type = load_b64_audio_data(base64_audio_string)
+ if audio_data is not None:
+ audio_url = upload_audio(
+ request,
+ audio_data,
+ content_type,
+ metadata,
+ user,
+ )
+ return audio_url
+ return None
+
+
+def get_file_url_from_base64(request, base64_file_string, metadata, user):
+ if "data:image/png;base64" in base64_file_string:
+ return get_image_url_from_base64(request, base64_file_string, metadata, user)
+ elif "data:audio/wav;base64" in base64_file_string:
+ return get_audio_url_from_base64(request, base64_file_string, metadata, user)
+ return None
diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py
index 1986e55b64..663b4e3fb7 100644
--- a/backend/open_webui/utils/filter.py
+++ b/backend/open_webui/utils/filter.py
@@ -127,8 +127,10 @@ async def process_filter_functions(
raise e
# Handle file cleanup for inlet
- if skip_files and "files" in form_data.get("metadata", {}):
- del form_data["files"]
- del form_data["metadata"]["files"]
+ if skip_files:
+ if "files" in form_data.get("metadata", {}):
+ del form_data["metadata"]["files"]
+ if "files" in form_data:
+ del form_data["files"]
return form_data, {}
diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py
new file mode 100644
index 0000000000..01df38886c
--- /dev/null
+++ b/backend/open_webui/utils/mcp/client.py
@@ -0,0 +1,110 @@
+import asyncio
+from typing import Optional
+from contextlib import AsyncExitStack
+
+from mcp import ClientSession
+from mcp.client.auth import OAuthClientProvider, TokenStorage
+from mcp.client.streamable_http import streamablehttp_client
+from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
+
+
+class MCPClient:
+ def __init__(self):
+ self.session: Optional[ClientSession] = None
+ self.exit_stack = AsyncExitStack()
+
+ async def connect(self, url: str, headers: Optional[dict] = None):
+ try:
+ self._streams_context = streamablehttp_client(url, headers=headers)
+
+ transport = await self.exit_stack.enter_async_context(self._streams_context)
+ read_stream, write_stream, _ = transport
+
+ self._session_context = ClientSession(
+ read_stream, write_stream
+ ) # pylint: disable=W0201
+
+ self.session = await self.exit_stack.enter_async_context(
+ self._session_context
+ )
+ await self.session.initialize()
+ except Exception as e:
+ await self.disconnect()
+ raise e
+
+ async def list_tool_specs(self) -> Optional[dict]:
+ if not self.session:
+ raise RuntimeError("MCP client is not connected.")
+
+ result = await self.session.list_tools()
+ tools = result.tools
+
+ tool_specs = []
+ for tool in tools:
+ name = tool.name
+ description = tool.description
+
+ inputSchema = tool.inputSchema
+
+ # TODO: handle outputSchema if needed
+ outputSchema = getattr(tool, "outputSchema", None)
+
+ tool_specs.append(
+ {"name": name, "description": description, "parameters": inputSchema}
+ )
+
+ return tool_specs
+
+ async def call_tool(
+ self, function_name: str, function_args: dict
+ ) -> Optional[dict]:
+ if not self.session:
+ raise RuntimeError("MCP client is not connected.")
+
+ result = await self.session.call_tool(function_name, function_args)
+ if not result:
+ raise Exception("No result returned from MCP tool call.")
+
+ result_dict = result.model_dump(mode="json")
+ result_content = result_dict.get("content", {})
+
+ if result.isError:
+ raise Exception(result_content)
+ else:
+ return result_content
+
+ async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
+ if not self.session:
+ raise RuntimeError("MCP client is not connected.")
+
+ result = await self.session.list_resources(cursor=cursor)
+ if not result:
+ raise Exception("No result returned from MCP list_resources call.")
+
+ result_dict = result.model_dump()
+ resources = result_dict.get("resources", [])
+
+ return resources
+
+ async def read_resource(self, uri: str) -> Optional[dict]:
+ if not self.session:
+ raise RuntimeError("MCP client is not connected.")
+
+ result = await self.session.read_resource(uri)
+ if not result:
+ raise Exception("No result returned from MCP read_resource call.")
+ result_dict = result.model_dump()
+
+ return result_dict
+
+ async def disconnect(self):
+ # Clean up and close the session
+ await self.exit_stack.aclose()
+
+ async def __aenter__(self):
+ await self.exit_stack.__aenter__()
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
+ await self.disconnect()
diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py
index 89e4304474..1ae340ae7b 100644
--- a/backend/open_webui/utils/middleware.py
+++ b/backend/open_webui/utils/middleware.py
@@ -20,9 +20,11 @@ from concurrent.futures import ThreadPoolExecutor
from fastapi import Request, HTTPException
+from fastapi.responses import HTMLResponse
from starlette.responses import Response, StreamingResponse, JSONResponse
+from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.chats import Chats
from open_webui.models.folders import Folders
from open_webui.models.users import Users
@@ -52,6 +54,11 @@ from open_webui.routers.pipelines import (
from open_webui.routers.memories import query_memory, QueryMemoryForm
from open_webui.utils.webhook import post_webhook
+from open_webui.utils.files import (
+ get_audio_url_from_base64,
+ get_file_url_from_base64,
+ get_image_url_from_base64,
+)
from open_webui.models.users import UserModel
@@ -73,10 +80,12 @@ from open_webui.utils.misc import (
add_or_update_system_message,
add_or_update_user_message,
get_last_user_message,
+ get_last_user_message_item,
get_last_assistant_message,
get_system_message,
prepend_to_first_user_message_content,
convert_logit_bias_input_to_json,
+ get_content_from_message,
)
from open_webui.utils.tools import get_tools
from open_webui.utils.plugin import load_function_module_by_id
@@ -86,6 +95,7 @@ from open_webui.utils.filter import (
)
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.payload import apply_system_prompt_to_body
+from open_webui.utils.mcp.client import MCPClient
from open_webui.config import (
@@ -125,6 +135,149 @@ DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("Tool Executed
\nTool Executed
\nExecuting...
\n