Merge pull request #20394 from open-webui/dev

0.7
This commit is contained in:
Tim Baek 2026-01-09 22:51:34 +04:00 committed by GitHub
commit 6adde203cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
234 changed files with 18048 additions and 6117 deletions

View file

@ -5,6 +5,124 @@ 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.7.0] - 2026-01-09
### Added
- 🤖 **Native Function Calling with Built-in Tools.** Users can now ask models to perform multi-step tasks that combine web research, knowledge base queries, note-taking, and image generation in a single conversation—for example, "research the latest on X, save key findings to a note, and generate an infographic." Requires models with native function calling support and function calling mode set to "Native" in Chat Controls. [#19397](https://github.com/open-webui/open-webui/issues/19397), [Commit](https://github.com/open-webui/open-webui/commit/5c1d52231a3997a17381c48639bd7e339262cf7c)
- 🧠 Users can now ask the model to find relevant context from their notes, past chats, and channel messages—for example, "what did I discuss about project X last week?" or "find the conversation where I brainstormed ideas for Y." [Commit](https://github.com/open-webui/open-webui/commit/646835d76744ad9b2e67ede0407a61d62e969aab)
- 📚 Users can now ask the model to search their knowledge bases and retrieve documents without manually attaching files—for example, "find the section about authentication in our API docs" or "what do our internal guidelines say about X?" [Commit](https://github.com/open-webui/open-webui/commit/c8622adcb01f3091b17ca50f8c8e2f20c7b9cd2a)
- 💭 Users with models that support interleaved thinking now get more refined results from multi-step workflows, as the model can analyze each tool's output before deciding what to do next.
- 🔍 When models invoke web search, search results appear as clickable citations in real-time for full source verification. [Commit](https://github.com/open-webui/open-webui/commit/2789f6a24d8405c30cd48ae460071f6a4f2c35f9)
- 🎚️ Users can selectively disable specific built-in tools (timestamps, memory, chat history, notes, web search, knowledge bases) per model via the model editor's capabilities settings. [Commit](https://github.com/open-webui/open-webui/commit/60e916d6c0c5f7db9e6d670e12be2d1d4abc2dd6)
- 👁️ Pending tool calls are now displayed during response generation, so users know which tools are being invoked. [Commit](https://github.com/open-webui/open-webui/commit/1d08376860e775049abd1dd5f568ac0c6466c944)
- 📁 Administrators can now limit the number of files that can be uploaded to folders using the "FOLDER_MAX_FILE_COUNT" setting, preventing resource exhaustion from bulk uploads. [#19810](https://github.com/open-webui/open-webui/issues/19810), [Commit](https://github.com/open-webui/open-webui/commit/a1036e544d573e3d35e05c1c2472ba762c32431b), [Commit](https://github.com/open-webui/open-webui/commit/d3ee3fd23e762c9d83fe1da5636d03259e186e57)
- ⚡ Users experience transformative speed improvements across the entire application through completely reengineered database connection handling, delivering noticeably faster page loads, butter-smooth interactions, and rock-solid stability during intensive operations like user management and bulk data processing. [Commit](https://github.com/open-webui/open-webui/commit/2041ab483e21b3a757baa25c47dc2fa29018674f), [Commit](https://github.com/open-webui/open-webui/commit/145c7516f227ce56fd52373cee86217fadf16181), [Commit](https://github.com/open-webui/open-webui/commit/475dd91ed798f2efcdf27799d5c5cae3f0e6e847), [Commit](https://github.com/open-webui/open-webui/commit/5d1459df166cce8445eb1556cc26abbd65a3f9f4), [Commit](https://github.com/open-webui/open-webui/commit/2453b75ff0fb2dc75b929d96f417d84e332922d9), [Commit](https://github.com/open-webui/open-webui/commit/5649a668fad15393a52c27a2f188841af8b66989)
- 🚀 Users experience significantly faster initial page load times through dynamic loading of document processing libraries, reducing the initial bundle size. [#20200](https://github.com/open-webui/open-webui/pull/20200), [#20202](https://github.com/open-webui/open-webui/pull/20202), [#20203](https://github.com/open-webui/open-webui/pull/20203), [#20204](https://github.com/open-webui/open-webui/pull/20204)
- 💨 Administrators experience dramatically faster user list loading through optimized database queries that eliminate N+1 query patterns, reducing query count from 1+N to just 2 total queries regardless of user count. [#20427](https://github.com/open-webui/open-webui/pull/20427)
- 📋 Notes now load faster through optimized database queries that batch user lookups instead of fetching each note's author individually. [Commit](https://github.com/open-webui/open-webui/commit/084f0ef6a5491e186bf6b71c6386973ba18ef2fa)
- 💬 Channel messages, pinned messages, and thread replies now load faster through batched user lookups instead of individual queries per message. [#20458](https://github.com/open-webui/open-webui/pull/20458), [#20459](https://github.com/open-webui/open-webui/pull/20459), [#20460](https://github.com/open-webui/open-webui/pull/20460)
- 🔗 Users can now click citation content links to jump directly to the relevant portion of source documents with automatic text highlighting, making it easier to verify AI responses against their original sources. [#20116](https://github.com/open-webui/open-webui/pull/20116), [Commit](https://github.com/open-webui/open-webui/commit/40c45ffe1f9b45538d32c8ecba8cac62c6eca503)
- 📌 Users can now pin or hide models directly from the Workspace Models page and Admin Settings Models page, making it easier to manage which models appear in the sidebar without switching to the chat interface. [#20176](https://github.com/open-webui/open-webui/pull/20176)
- 🔎 Administrators can now quickly find settings using the new search bar in the Admin Settings sidebar, which supports fuzzy filtering by category names and related keywords like "whisper" for Audio or "rag" for Documents. [#20434](https://github.com/open-webui/open-webui/pull/20434)
- 🎛️ Users can now view read-only models in the workspace models list, with clear "Read Only" badges indicating when editing is restricted. [#20243](https://github.com/open-webui/open-webui/issues/20243), [#20369](https://github.com/open-webui/open-webui/pull/20369)
- 📝 Users can now view read-only prompts in the workspace prompts list, with clear "Read Only" badges indicating when editing is restricted. [#20368](https://github.com/open-webui/open-webui/pull/20368)
- 🔧 Users can now view read-only tools in the workspace tools list, with clear "Read Only" badges indicating when editing is restricted. [#20243](https://github.com/open-webui/open-webui/issues/20243), [#20370](https://github.com/open-webui/open-webui/pull/20370)
- 📂 Searching for files is now significantly faster, especially for users with large file collections. [Commit](https://github.com/open-webui/open-webui/commit/a9a979fb3db1743553ca0705f571c0b9c252841f)
- 🏆 The Evaluations leaderboard now calculates Elo ratings on the backend instead of in the browser, improving performance and enabling topic-based model ranking through semantic search. [#15392](https://github.com/open-webui/open-webui/pull/15392), [#20476](https://github.com/open-webui/open-webui/issues/20476), [Commit](https://github.com/open-webui/open-webui/commit/10838b3654bf6fdef02d57311f7f1c01df4cd033)
- 📊 The Evaluations leaderboard now includes a per-model activity chart displaying daily wins and losses as a diverging bar chart, with 30-day, 1-year, and all-time views using weekly aggregation for longer timeframes.
- 🎞️ Users can now upload animated GIF and WebP formats as model profile images, with animation preserved by skipping resize processing for these file types. [Commit](https://github.com/open-webui/open-webui/commit/00af37bb4ed1ea0957c7c84a6a8def3a7998b8ca)
- 📸 Users uploading profile images for users, models, and arena models now benefit from WebP compression at 80% quality instead of JPEG, resulting in significantly smaller file sizes and faster uploads while maintaining visual quality. [Commit](https://github.com/open-webui/open-webui/commit/b1d30673b69571e081abb881d34944cf33cdc67e)
- ⭐ Action Function developers can now update message favorite status using the new "chat:message:favorite" event, enabling the development of pin/unpin message actions without race conditions from frontend auto-save. [#20375](https://github.com/open-webui/open-webui/pull/20375)
- 🌐 Users with OpenAI-compatible models that have web search capabilities now see URL citations displayed as sources in the interface. [#20172](https://github.com/open-webui/open-webui/pull/20172), [Commit](https://github.com/open-webui/open-webui/commit/fe84afd09a2bc8a89311f30186ec2608a4edda3a)
- 📰 Users can now dismiss the "What's New" changelog modal permanently using the X button, matching the behavior of the "Okay, Let's Go!" button. [#20258](https://github.com/open-webui/open-webui/pull/20258)
- 📧 Administrators can now configure the admin contact email displayed in the Account Pending overlay directly from the Admin Panel instead of only through environment variables. [#12500](https://github.com/open-webui/open-webui/issues/12500), [#20260](https://github.com/open-webui/open-webui/pull/20260)
- 📄 Administrators can now enable markdown header text splitting as a preprocessing step that works with either character or token splitting, through the new "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER" setting. [Commit](https://github.com/open-webui/open-webui/commit/d3a682759f375c6cb0bc8c170f62863a070f712e), [Commit](https://github.com/open-webui/open-webui/commit/18a33a079bf07487edffc421a721c86194cc90c9), [Commit](https://github.com/open-webui/open-webui/commit/08bf4670ec862018f9dc57296cb19fd5eab14ef6)
- 🧩 Administrators can now set a minimum chunk size target using the "CHUNK_MIN_SIZE_TARGET" setting to merge small markdown header chunks with neighbors, which improves retrieval quality by eliminating tiny meaningless fragments, significantly speeds up document processing and embedding, reduces storage costs, and lowers embedding API costs or local compute requirements. [#19595](https://github.com/open-webui/open-webui/issues/19595), [#20314](https://github.com/open-webui/open-webui/pull/20314), [Commit](https://github.com/open-webui/open-webui/commit/c32435958073cf002d87e78544baa88bc4e15d7f)
- 💨 Administrators can now enable KV prefix caching optimization by setting "RAG_SYSTEM_CONTEXT" to true, which injects RAG context into the system message instead of user messages, enabling models to reuse cached tokens for follow-up questions instead of reprocessing the entire context on each turn, significantly improving response times and reducing costs for cloud-based models. [#20301](https://github.com/open-webui/open-webui/discussions/20301), [#20317](https://github.com/open-webui/open-webui/pull/20317)
- 🖼️ Administrators and Action developers can now control image generation denoising steps per-request using a steps parameter, allowing Actions and API calls to override the global IMAGE_STEPS configuration for both ComfyUI and Automatic1111 engines. [#20337](https://github.com/open-webui/open-webui/pull/20337)
- 🗄️ Administrators running multi-pod deployments can now designate a master pod to handle database migrations using the "ENABLE_DB_MIGRATIONS" environment variable. [Commit](https://github.com/open-webui/open-webui/commit/9824f0e33359a917ac07b60bf1f972074d5c8203)
- 🎙️ Administrators can now configure Whisper's compute type using the "WHISPER_COMPUTE_TYPE" environment variable to fix compatibility issues with CUDA/GPU deployments. [Commit](https://github.com/open-webui/open-webui/commit/26af1f92e21ddfd08348570bf54a6f345ac69648)
- 🔍 Administrators can now control sigmoid normalization for CrossEncoder reranking models using the "SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION" environment variable, enabled by default for proper relevance threshold behavior with MS MARCO models. [#20228](https://github.com/open-webui/open-webui/pull/20228)
- 🔒 Administrators can now disable SSL certificate verification for external tools using the "REQUESTS_VERIFY" environment variable, enabling integration with self-signed certificates for Tika, Ollama embeddings, and external rerankers. [#19968](https://github.com/open-webui/open-webui/issues/19968), [Commit](https://github.com/open-webui/open-webui/commit/dfc5dad63167eabb7fb027e63c324675b23f2e9d)
- 📈 Administrators can now control audit log output destinations using "ENABLE_AUDIT_STDOUT" and "ENABLE_AUDIT_LOGS_FILE" environment variables, allowing audit logs to be sent to container logs for centralized logging systems. [#20114](https://github.com/open-webui/open-webui/pull/20114), [Commit](https://github.com/open-webui/open-webui/commit/fdae5644e36972384b3e2513e3074f95f9f7381f)
- 🛡️ Administrators can now restrict non-admin user access to Interface Settings through per-user or per-group permissions. [#20424](https://github.com/open-webui/open-webui/pull/20424)
- 🧠 Administrators can now globally enable or disable the Memories feature and control access through per-user or per-group permissions, with the Personalization tab automatically hidden when the feature is disabled. [#20462](https://github.com/open-webui/open-webui/pull/20462)
- 🟢 Administrators can now globally enable or disable user status visibility through the "ENABLE_USER_STATUS" setting in Admin Settings. [#20488](https://github.com/open-webui/open-webui/pull/20488)
- 🪝 Channel managers can now create webhooks to allow external services to post messages to channels without authentication. [Commit](https://github.com/open-webui/open-webui/commit/cd296fcf0d79cecd1a6a3ee4e492c6b5246ca7ae)
- 📄 In the model editor users can now disable the "File Context" capability to skip automatic file content extraction and injection, forwarding raw messages with file attachment metadata instead for use with custom tools or future built-in file access tools. [Commit](https://github.com/open-webui/open-webui/commit/daccf0713e3ecd6d24f003a87b5f8b3c61852958), [Docs:Commit](https://github.com/open-webui/docs/commit/18ec6eaefc071a278ec57d4d1b8d66d686af0870)
- 🔊 In the model editor users can now configure a specific TTS voice for each model, overriding user preferences and global defaults to give different AI personas distinct voices. [#3097](https://github.com/open-webui/open-webui/issues/3097), [Commit](https://github.com/open-webui/open-webui/commit/bb6188abf04302f79d80b0d6cc42c232624b5757)
- 👥 Administrators now have three granular group sharing permission options instead of a simple on/off toggle, allowing them to choose between "No one", "Members", or "Anyone" for who can share content to each group. [Commit](https://github.com/open-webui/open-webui/commit/ca514cd3eda2524b8da472ef17c0ccb216bac2e8)
- 📦 Administrators can now export knowledge bases as zip files containing text files for backup and archival purposes. [#20120](https://github.com/open-webui/open-webui/issues/20120), [Commit](https://github.com/open-webui/open-webui/commit/c1147578c073a8c7fa7e7f836149e1cdfec8f18d)
- 🚀 Administrators can now create an admin account automatically at startup via "WEBUI_ADMIN_EMAIL", "WEBUI_ADMIN_PASSWORD", and "WEBUI_ADMIN_NAME" environment variables, enabling headless and automated deployments without exposing the signup page. [#17654](https://github.com/open-webui/open-webui/issues/17654), [Commit](https://github.com/open-webui/open-webui/commit/1138929f4d083931305f1f925899971b190562ae)
- 🦆 Administrators can now select a specific search backend for DDGS instead of random selection, with options including Bing, Brave, DuckDuckGo, Google, Wikipedia, Yahoo, and others. [#20330](https://github.com/open-webui/open-webui/issues/20330), [#20366](https://github.com/open-webui/open-webui/pull/20366)
- 🧭 Administrators can now configure custom Jina Search API endpoints using the "JINA_API_BASE_URL" environment variable, enabling region-specific deployments such as EU data processing. [#19718](https://github.com/open-webui/open-webui/pull/19718), [Commit](https://github.com/open-webui/open-webui/commit/f7f8a263b92289df8d4f8dbc3bae09bd009a5699)
- 🔥 Administrators can now configure Firecrawl timeout values using the "FIRECRAWL_TIMEOUT" environment variable to control web scraping wait times. [#19973](https://github.com/open-webui/open-webui/pull/19973), [Commit](https://github.com/open-webui/open-webui/commit/89ad1c68d1aadf849960b5e202aa4651096b05f5)
- 💾 Administrators can now use openGauss as the vector database backend for knowledge base document storage and retrieval. [#20179](https://github.com/open-webui/open-webui/pull/20179)
- 🔄 Various improvements were implemented across the application to enhance performance, stability, and security.
- 📊 Users can now sync their anonymous usage statistics to the Open WebUI Community platform to power community leaderboards, drive model evaluations, and contribute to open-source AI research that benefits everyone, all while keeping conversations completely private (only metadata like model names, message counts, and ratings are shared). By sharing your stats, you're helping the community identify which models perform best, contributing to transparent AI benchmarking, and supporting the collective effort to make AI better for all. You can also download your stats as JSON for personal analysis.
- 🌐 Translations for German, Portuguese (Brazil), Spanish, Simplified Chinese, Traditional Chinese, and Polish were enhanced and expanded.
### Fixed
- 🔊 Text-to-speech now correctly splits on newlines in addition to punctuation, so markdown bullet points and lists are spoken as separate sentences instead of being merged together. [#5924](https://github.com/open-webui/open-webui/issues/5924), [Commit](https://github.com/open-webui/open-webui/commit/869108a3e1ce2b8110084113c1b392072e98fd5f)
- 🔒 Users are now protected from stored XSS vulnerabilities in iFrame embeds for citations and response messages through configurable same-origin sandbox settings instead of hardcoded values. [#20209](https://github.com/open-webui/open-webui/pull/20209), [#20210](https://github.com/open-webui/open-webui/pull/20210)
- 🔑 Image Generation, Web Search, and Audio (TTS/STT) API endpoints now enforce permission checks on the backend, closing a security gap where disabled features could previously be accessed via direct API calls. [#20471](https://github.com/open-webui/open-webui/pull/20471)
- 🛠️ Tools and Tool Servers (MCP and OpenAPI) now enforce access control checks on the backend, ensuring users can only access tools they have permission to use even via direct API calls. [#20443](https://github.com/open-webui/open-webui/issues/20443), [Commit](https://github.com/open-webui/open-webui/commit/9b06fdc8fe1c933071610336be05f11e77e6c8eb)
- 🔁 System prompts are no longer duplicated when using native function calling, fixing an issue where the prompt would be applied twice during tool-calling workflows. [Commit](https://github.com/open-webui/open-webui/commit/9223efaff0db6e56bfa157ef214d9590005156d2)
- 🗂️ Knowledge base uploads to folders no longer fail when "FOLDER_MAX_FILE_COUNT" is unset, fixing an issue where the default null value caused all uploads to error. [Commit](https://github.com/open-webui/open-webui/commit/ef9cd0e0ad6e45b8a3efec6f3858b3d69d42f619)
- 📝 The "Create Note" button in the chat input now correctly hides for users without Notes permissions instead of showing and returning a 401 error when clicked. [#20486](https://github.com/open-webui/open-webui/issues/20486), [Commit](https://github.com/open-webui/open-webui/commit/9e9616b670c1c4389193b18500a7d80d86d7e280)
- 📊 The Evaluations page no longer crashes when administrators have large amounts of feedback data, as the leaderboard now fetches only the minimal required fields instead of loading entire conversation snapshots. [#20476](https://github.com/open-webui/open-webui/issues/20476), [#20489](https://github.com/open-webui/open-webui/pull/20489), [Commit](https://github.com/open-webui/open-webui/commit/b2a1f71d920e55b143f1c02e61104938d2588762)
- 💬 Users can now export chats, use the Ask/Explain popup, and view chat lists correctly again after these features were broken by recent refactoring changes that caused 500 and 400 server errors. [#20146](https://github.com/open-webui/open-webui/issues/20146), [#20205](https://github.com/open-webui/open-webui/issues/20205), [#20206](https://github.com/open-webui/open-webui/issues/20206), [#20212](https://github.com/open-webui/open-webui/pull/20212)
- 💭 Users no longer experience data corruption when switching between chats during background operations like image generation, where messages from one chat would appear in another chat's history. [#20266](https://github.com/open-webui/open-webui/pull/20266)
- 🛡️ Users no longer encounter critical chat stability errors, including duplicate key errors from circular message dependencies, null message access during chat loading, and errors in the chat overview visualization. [#20268](https://github.com/open-webui/open-webui/pull/20268)
- 📡 Users with Channels no longer experience infinite recursion and connection pool exhaustion when fetching threaded replies, preventing RecursionError crashes during chat history loading. [#20299](https://github.com/open-webui/open-webui/pull/20299), [Commit](https://github.com/open-webui/open-webui/commit/c144122f608759c2b79472e1f6948a7c1600a3d1)
- 📎 Users no longer encounter TypeError crashes when viewing messages with file attachments that have undefined URL properties. [#20343](https://github.com/open-webui/open-webui/pull/20343)
- 🔐 Users with MCP integrations now experience reliable OAuth 2.1 token refresh after access token expiration through proper Protected Resource discovery, preventing integration failures that caused sessions to be deleted. [#19794](https://github.com/open-webui/open-webui/issues/19794), [#20138](https://github.com/open-webui/open-webui/pull/20138), [#20291](https://github.com/open-webui/open-webui/issues/20291), [Commit](https://github.com/open-webui/open-webui/commit/bf2b2962399e341926bdbf9e0a82101f31a90b23), [Commit](https://github.com/open-webui/open-webui/commit/89565c58c6ae6b5b129559ef68b5a0c18c110765)
- 📚 Users who belong to multiple groups can now see Knowledge Bases shared with those groups, fixing an issue where they would disappear when shared with more than one group. [#20124](https://github.com/open-webui/open-webui/issues/20124), [#20229](https://github.com/open-webui/open-webui/issues/20229), [Commit](https://github.com/open-webui/open-webui/commit/61e25dc2dce9c12dcb5b88a6b814060c4338e67b)
- 📂 Users now see the correct Knowledge Base name when hovering over # file references in chat input instead of "undefined". [#20329](https://github.com/open-webui/open-webui/issues/20329), [#20333](https://github.com/open-webui/open-webui/pull/20333)
- 📋 Users now see notes displayed in correct chronological order within their time range groupings, fixing an issue where insertion order was not preserved. [Commit](https://github.com/open-webui/open-webui/commit/3f577c0c3fbfd9f09c02940e4ae474f987149277)
- 📑 Users collaborating on notes now experience proper content sync when initializing from both HTML and JSON formats, fixing sync failures in collaborative editing sessions. [Commit](https://github.com/open-webui/open-webui/commit/e27fb3e291a735c715a089a80e7a49d2c2209096)
- 🔎 Users searching notes can now find hyphenated words and variations with spaces, so searching "todo" now finds "to-do" and "to do". [Commit](https://github.com/open-webui/open-webui/commit/a3270648d8b8535443d8ce2ea719f8e678e4e358)
- 📥 Users no longer experience false duplicate file warnings when reuploading files after initial processing failed, as the file hash is now only stored after successful processing completion. [#19264](https://github.com/open-webui/open-webui/issues/19264), [#20282](https://github.com/open-webui/open-webui/pull/20282), [Commit](https://github.com/open-webui/open-webui/commit/d3ab9f4b96eee7f91c9b1355cee055fdabca9730)
- 💾 Users experience significantly improved page load performance as model profile images now cache properly in browsers, avoiding unnecessary image refetches. [Commit](https://github.com/open-webui/open-webui/commit/bb821ab654e93908a3b4632c359753eeff053264)
- 🎨 Users can now successfully edit uploaded images instead of having new images generated, fixing an issue introduced by the file storage refactor where images with type "file" and content_type starting with "image/" weren't being recognized as editable images. [#20237](https://github.com/open-webui/open-webui/issues/20237), [#20169](https://github.com/open-webui/open-webui/pull/20169), [#20239](https://github.com/open-webui/open-webui/pull/20239), [Commit](https://github.com/open-webui/open-webui/commit/1148d1c927d096e14917b6d762789fca3188f281)
- 🌐 Users writing in Persian and Arabic now see properly displayed right-to-left text in the notes section through automatic text direction detection. [#19743](https://github.com/open-webui/open-webui/issues/19743), [#20102](https://github.com/open-webui/open-webui/pull/20102), [Commit](https://github.com/open-webui/open-webui/commit/b619a157bc54c5bc44d223d2ae3acb9ce4ac6a6c)
- 🤖 Users can now successfully @ mention models in Channels instead of experiencing silent failures. [Commit](https://github.com/open-webui/open-webui/commit/59957715836acb635f4b1c4ddbfb4ba7b82b3281)
- 📋 Users on Windows now see correctly preserved line breaks when using the {{CLIPBOARD}} variable through CRLF to LF normalization. [#19370](https://github.com/open-webui/open-webui/issues/19370), [#20283](https://github.com/open-webui/open-webui/pull/20283)
- 📁 Users now see the Knowledge Selector dropdown correctly displayed above the Create Folder modal instead of being hidden behind it. [#20219](https://github.com/open-webui/open-webui/issues/20219), [#20213](https://github.com/open-webui/open-webui/pull/20213)
- 🌅 Users now see profile images in non-PNG formats like SVG, JPEG, and GIF displayed correctly instead of appearing broken. [#20171](https://github.com/open-webui/open-webui/pull/20171)
- 🆕 Non-admin users with disabled temporary chat permissions can now successfully create new chats and use pinned models from the sidebar. [#20336](https://github.com/open-webui/open-webui/issues/20336), [#20367](https://github.com/open-webui/open-webui/pull/20367), [Commit](https://github.com/open-webui/open-webui/commit/e754940c031f9689fb4f6edb3625aa06aeb53377)
- 🎛️ Users can now successfully use workspace models in chat, fixing "Model not found" errors that occurred when using custom model presets. [#20340](https://github.com/open-webui/open-webui/issues/20340), [#20344](https://github.com/open-webui/open-webui/pull/20344), [Commit](https://github.com/open-webui/open-webui/commit/b55a46ae99c32068ed306a5ecdaafa9f75504cd7), [Commit](https://github.com/open-webui/open-webui/commit/2bb13d5dbc6e233856e8aa26143222ceda8f6c11)
- 🔁 Users can now regenerate messages without crashes when the parent message is missing or corrupted in the chat history. [#20264](https://github.com/open-webui/open-webui/pull/20264)
- ✏️ Users no longer experience TipTap rich text editor crashes with "editor view is not available" errors when plugins or async methods try to access the editor after it has been destroyed. [#20266](https://github.com/open-webui/open-webui/pull/20266)
- 📗 Administrators with bypass access control enabled now correctly have write access to all knowledge bases. [#20371](https://github.com/open-webui/open-webui/pull/20371)
- 🔍 Administrators using local CrossEncoder reranking models now see proper relevance threshold behavior through MS MARCO model score normalization to the 0-1 range via sigmoid activation. [#19999](https://github.com/open-webui/open-webui/issues/19999), [#20228](https://github.com/open-webui/open-webui/pull/20228)
- 🎯 Administrators using local SentenceTransformers embedding engine now benefit from proper batch size settings, preventing excessive memory usage from the default batch size of 32. [#20053](https://github.com/open-webui/open-webui/issues/20053), [#20054](https://github.com/open-webui/open-webui/pull/20054), [Commit](https://github.com/open-webui/open-webui/commit/e4a5b06ca68303512678b4d2dc296bc78b9f983f)
- 🔧 Administrators and users in offline mode or restricted environments like uv, poetry, and NixOS no longer experience crashes when Tools and Functions have frontmatter requirements, as pip installation is now skipped when offline mode is enabled. [#20320](https://github.com/open-webui/open-webui/issues/20320), [#20321](https://github.com/open-webui/open-webui/pull/20321), [Commit](https://github.com/open-webui/open-webui/commit/bd07ef8)
- 📄 Administrators can now properly configure the MinerU document parsing service as the MinerU Cloud API key field is now available in the Admin Panel Documents settings. [#20319](https://github.com/open-webui/open-webui/issues/20319), [#20328](https://github.com/open-webui/open-webui/pull/20328)
- ⚠️ Administrators no longer see SyntaxWarnings for invalid escape sequences in password validation regex patterns. [#20298](https://github.com/open-webui/open-webui/pull/20298), [Commit](https://github.com/open-webui/open-webui/commit/e55bf2c2ac391caed871d41f0484820091081908)
- 🎨 Users with ComfyUI workflows now see only the intended final output images in chat instead of duplicate images from intermediate processing nodes like masks, crops, or segmentation previews. [#20158](https://github.com/open-webui/open-webui/issues/20158), [#20182](https://github.com/open-webui/open-webui/pull/20182)
- 🖼️ Users with image generation enabled no longer see false vision capability warnings, allowing them to send follow-up messages after generating images and to send images to non-vision models for image editing. [#20129](https://github.com/open-webui/open-webui/issues/20129), [#20256](https://github.com/open-webui/open-webui/pull/20256)
- 🔌 Administrators no longer experience infinite loading screens when invalid or MCP-style configurations are used with OpenAPI connection types for external tools. [#20207](https://github.com/open-webui/open-webui/issues/20207), [#20257](https://github.com/open-webui/open-webui/pull/20257)
- 📥 Administrators no longer encounter TypeError crashes during SHA256 verification when uploading GGUF models via URL, fixing 500 Internal Server Error crashes. [#20263](https://github.com/open-webui/open-webui/issues/20263)
- 🚦 Users with Brave Search now experience automatic retry with a 1-second delay when hitting rate limits, preventing failures when sequential requests exceed the 1 request per second limit, though this only works reliably when web search concurrency is set to a maximum of 1. [#15134](https://github.com/open-webui/open-webui/issues/15134), [#20255](https://github.com/open-webui/open-webui/pull/20255)
- 🗄️ Administrators with Redis Sentinel deployments no longer experience crashes during websocket disconnections due to improper async-generator handling in the YDocManager. [#20142](https://github.com/open-webui/open-webui/issues/20142), [#20145](https://github.com/open-webui/open-webui/pull/20145)
- 🔐 Administrators using SCIM group management no longer encounter 500 errors when working with groups that have no members. [#20187](https://github.com/open-webui/open-webui/pull/20187)
- 🔗 Users now experience more reliable citations from AI models, especially when using smaller or weaker models that may not format citation references perfectly. [Commit](https://github.com/open-webui/open-webui/commit/c0ec04935b4eea3d334bfdec2fc41278f1085a49)
- 🕸️ Administrators can now successfully save WebSearch settings without encountering validation errors for domain filter lists, YouTube language settings, or timeout values. [#20422](https://github.com/open-webui/open-webui/pull/20422)
- 📦 Administrators installing with the uv package manager now experience successful installation after deprecated dependencies that were causing conflicts were removed. [#20177](https://github.com/open-webui/open-webui/issues/20177), [#20192](https://github.com/open-webui/open-webui/pull/20192)
- ⏱️ Administrators using custom "AIOHTTP_CLIENT_TIMEOUT" settings now see the configured timeout correctly applied to embedding generation, OAuth discovery, webhook calls, and tool/function loading instead of falling back to the default 300-second timeout. [Commit](https://github.com/open-webui/open-webui/commit/e67891a374625d9888ec391da561f0b4ed79ed5d)
### Changed
- ⚠️ This release includes a major overhaul of database connection handling in the backend that requires all instances in multi-worker, multi-server, or load-balanced deployments to be updated simultaneously; running mixed versions will cause failures due to incompatible database connection management between old and new instances.
- 📝 Administrators who previously used the standalone "Markdown (Header)" text splitter must now switch to "character" or "token" mode with the new "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER" toggle enabled, as document chunking now applies markdown header splitting as a preprocessing step before character or token splitting. [Commit](https://github.com/open-webui/open-webui/commit/d3a682759f375c6cb0bc8c170f62863a070f712e), [Commit](https://github.com/open-webui/open-webui/commit/18a33a079bf07487edffc421a721c86194cc90c9), [Commit](https://github.com/open-webui/open-webui/commit/08bf4670ec862018f9dc57296cb19fd5eab14ef6)
- 🖼️ Users no longer see the "Generate Image" action button in chat message interfaces; custom function should be used. [Commit](https://github.com/open-webui/open-webui/commit/f0829ba6e6fd200702fb76efc43dd785cf87fec9)
- 🔗 Administrators will find the Admin Evaluations page at the new URL "/admin/evaluations/feedback" instead of "/admin/evaluations/feedbacks" to use the correct uncountable form of the word. [#20296](https://github.com/open-webui/open-webui/pull/20296)
- 🔐 Scripts or integrations that directly called Image Generation, Web Search, or Audio APIs while those features were disabled in the Admin UI will now receive 403 Forbidden errors, as backend permission enforcement has been added to match frontend restrictions. [#20471](https://github.com/open-webui/open-webui/pull/20471)
- 👥 The default group sharing permission changed from "Members" to "Anyone", meaning users can now share content to any group configured with "Anyone" permission regardless of their membership in that group. [Commit](https://github.com/open-webui/open-webui/commit/ca514cd3eda2524b8da472ef17c0ccb216bac2e8)
## [0.6.43] - 2025-12-22
### Fixed

View file

@ -13,6 +13,7 @@ ARG USE_CUDA_VER=cu128
# IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them.
ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
ARG USE_RERANKING_MODEL=""
ARG USE_AUXILIARY_EMBEDDING_MODEL=TaylorAI/bge-micro-v2
# Tiktoken encoding name; models to use can be found at https://huggingface.co/models?library=tiktoken
ARG USE_TIKTOKEN_ENCODING_NAME="cl100k_base"
@ -42,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
RUN npm run build
######## WebUI backend ########
FROM python:3.11-slim-bookworm AS base
FROM python:3.11.14-slim-bookworm AS base
# Use args
ARG USE_CUDA
@ -52,6 +53,7 @@ ARG USE_SLIM
ARG USE_PERMISSION_HARDENING
ARG USE_EMBEDDING_MODEL
ARG USE_RERANKING_MODEL
ARG USE_AUXILIARY_EMBEDDING_MODEL
ARG UID
ARG GID
@ -67,7 +69,8 @@ ENV ENV=prod \
USE_SLIM_DOCKER=${USE_SLIM} \
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL} \
USE_AUXILIARY_EMBEDDING_MODEL_DOCKER=${USE_AUXILIARY_EMBEDDING_MODEL}
## Basis URL Config ##
ENV OLLAMA_BASE_URL="/ollama" \
@ -88,6 +91,7 @@ ENV WHISPER_MODEL="base" \
## RAG Embedding model settings ##
ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \
RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \
AUXILIARY_EMBEDDING_MODEL="$USE_AUXILIARY_EMBEDDING_MODEL_DOCKER" \
SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models"
## Tiktoken model settings ##
@ -136,6 +140,7 @@ RUN pip3 install --no-cache-dir uv && \
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
else \
@ -143,6 +148,7 @@ RUN pip3 install --no-cache-dir uv && \
uv pip install --system -r requirements.txt --no-cache-dir && \
if [ "$USE_SLIM" != "true" ]; then \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
fi; \

View file

@ -19,6 +19,7 @@ from authlib.integrations.starlette_client import OAuth
from open_webui.env import (
DATA_DIR,
DATABASE_URL,
ENABLE_DB_MIGRATIONS,
ENV,
REDIS_URL,
REDIS_KEY_PREFIX,
@ -67,7 +68,8 @@ def run_migrations():
log.exception(f"Error running migrations: {e}")
run_migrations()
if ENABLE_DB_MIGRATIONS:
run_migrations()
class Config(Base):
@ -1468,6 +1470,15 @@ USER_PERMISSIONS_FEATURES_API_KEYS = (
os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true"
)
USER_PERMISSIONS_FEATURES_MEMORIES = (
os.environ.get("USER_PERMISSIONS_FEATURES_MEMORIES", "True").lower() == "true"
)
USER_PERMISSIONS_SETTINGS_INTERFACE = (
os.environ.get("USER_PERMISSIONS_SETTINGS_INTERFACE", "True").lower() == "true"
)
DEFAULT_USER_PERMISSIONS = {
"workspace": {
@ -1526,6 +1537,10 @@ DEFAULT_USER_PERMISSIONS = {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
"code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
"memories": USER_PERMISSIONS_FEATURES_MEMORIES,
},
"settings": {
"interface": USER_PERMISSIONS_SETTINGS_INTERFACE,
},
}
@ -1541,6 +1556,12 @@ ENABLE_FOLDERS = PersistentConfig(
os.environ.get("ENABLE_FOLDERS", "True").lower() == "true",
)
FOLDER_MAX_FILE_COUNT = PersistentConfig(
"FOLDER_MAX_FILE_COUNT",
"folders.max_file_count",
os.environ.get("FOLDER_MAX_FILE_COUNT", ""),
)
ENABLE_CHANNELS = PersistentConfig(
"ENABLE_CHANNELS",
"channels.enable",
@ -1553,6 +1574,12 @@ ENABLE_NOTES = PersistentConfig(
os.environ.get("ENABLE_NOTES", "True").lower() == "true",
)
ENABLE_USER_STATUS = PersistentConfig(
"ENABLE_USER_STATUS",
"users.enable_status",
os.environ.get("ENABLE_USER_STATUS", "True").lower() == "true",
)
ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig(
"ENABLE_EVALUATION_ARENA_MODELS",
"evaluation.arena.enable",
@ -2067,6 +2094,12 @@ ENABLE_CODE_INTERPRETER = PersistentConfig(
os.environ.get("ENABLE_CODE_INTERPRETER", "True").lower() == "true",
)
ENABLE_MEMORIES = PersistentConfig(
"ENABLE_MEMORIES",
"memories.enable",
os.environ.get("ENABLE_MEMORIES", "True").lower() == "true",
)
CODE_INTERPRETER_ENGINE = PersistentConfig(
"CODE_INTERPRETER_ENGINE",
"code_interpreter.engine",
@ -2340,6 +2373,51 @@ else:
except Exception:
PGVECTOR_IVFFLAT_LISTS = 100
# openGauss
OPENGAUSS_DB_URL = os.environ.get("OPENGAUSS_DB_URL", DATABASE_URL)
OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
OPENGAUSS_POOL_SIZE = os.environ.get("OPENGAUSS_POOL_SIZE", None)
if OPENGAUSS_POOL_SIZE != None:
try:
OPENGAUSS_POOL_SIZE = int(OPENGAUSS_POOL_SIZE)
except Exception:
OPENGAUSS_POOL_SIZE = None
OPENGAUSS_POOL_MAX_OVERFLOW = os.environ.get("OPENGAUSS_POOL_MAX_OVERFLOW", 0)
if OPENGAUSS_POOL_MAX_OVERFLOW == "":
OPENGAUSS_POOL_MAX_OVERFLOW = 0
else:
try:
OPENGAUSS_POOL_MAX_OVERFLOW = int(OPENGAUSS_POOL_MAX_OVERFLOW)
except Exception:
OPENGAUSS_POOL_MAX_OVERFLOW = 0
OPENGAUSS_POOL_TIMEOUT = os.environ.get("OPENGAUSS_POOL_TIMEOUT", 30)
if OPENGAUSS_POOL_TIMEOUT == "":
OPENGAUSS_POOL_TIMEOUT = 30
else:
try:
OPENGAUSS_POOL_TIMEOUT = int(OPENGAUSS_POOL_TIMEOUT)
except Exception:
OPENGAUSS_POOL_TIMEOUT = 30
OPENGAUSS_POOL_RECYCLE = os.environ.get("OPENGAUSS_POOL_RECYCLE", 3600)
if OPENGAUSS_POOL_RECYCLE == "":
OPENGAUSS_POOL_RECYCLE = 3600
else:
try:
OPENGAUSS_POOL_RECYCLE = int(OPENGAUSS_POOL_RECYCLE)
except Exception:
OPENGAUSS_POOL_RECYCLE = 3600
# Pinecone
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
@ -2815,6 +2893,12 @@ RAG_TEXT_SPLITTER = PersistentConfig(
os.environ.get("RAG_TEXT_SPLITTER", ""),
)
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = PersistentConfig(
"ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER",
"rag.enable_markdown_header_text_splitter",
os.environ.get("ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER", "True").lower() == "true",
)
TIKTOKEN_CACHE_DIR = os.environ.get("TIKTOKEN_CACHE_DIR", f"{CACHE_DIR}/tiktoken")
TIKTOKEN_ENCODING_NAME = PersistentConfig(
@ -2827,6 +2911,13 @@ TIKTOKEN_ENCODING_NAME = PersistentConfig(
CHUNK_SIZE = PersistentConfig(
"CHUNK_SIZE", "rag.chunk_size", int(os.environ.get("CHUNK_SIZE", "1000"))
)
CHUNK_MIN_SIZE_TARGET = PersistentConfig(
"CHUNK_MIN_SIZE_TARGET",
"rag.chunk_min_size_target",
int(os.environ.get("CHUNK_MIN_SIZE_TARGET", "0")),
)
CHUNK_OVERLAP = PersistentConfig(
"CHUNK_OVERLAP",
"rag.chunk_overlap",
@ -3127,12 +3218,24 @@ SERPLY_API_KEY = PersistentConfig(
os.getenv("SERPLY_API_KEY", ""),
)
DDGS_BACKEND = PersistentConfig(
"DDGS_BACKEND",
"rag.web.search.ddgs_backend",
os.getenv("DDGS_BACKEND", "auto"),
)
JINA_API_KEY = PersistentConfig(
"JINA_API_KEY",
"rag.web.search.jina_api_key",
os.getenv("JINA_API_KEY", ""),
)
JINA_API_BASE_URL = PersistentConfig(
"JINA_API_BASE_URL",
"rag.web.search.jina_api_base_url",
os.getenv("JINA_API_BASE_URL", ""),
)
SEARCHAPI_API_KEY = PersistentConfig(
"SEARCHAPI_API_KEY",
"rag.web.search.searchapi_api_key",
@ -3267,6 +3370,12 @@ FIRECRAWL_API_BASE_URL = PersistentConfig(
os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"),
)
FIRECRAWL_TIMEOUT = PersistentConfig(
"FIRECRAWL_TIMEOUT",
"rag.web.loader.firecrawl_timeout",
os.environ.get("FIRECRAWL_TIMEOUT", ""),
)
EXTERNAL_WEB_SEARCH_URL = PersistentConfig(
"EXTERNAL_WEB_SEARCH_URL",
"rag.web.search.external_web_search_url",
@ -3628,17 +3737,16 @@ WHISPER_MODEL = PersistentConfig(
os.getenv("WHISPER_MODEL", "base"),
)
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models")
WHISPER_MODEL_AUTO_UPDATE = (
not OFFLINE_MODE
and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
)
WHISPER_VAD_FILTER = PersistentConfig(
"WHISPER_VAD_FILTER",
"audio.stt.whisper_vad_filter",
os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true",
)
WHISPER_VAD_FILTER = os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true"
WHISPER_MULTILINGUAL = os.getenv("WHISPER_MULTILINGUAL", "False").lower() == "true"
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None

View file

@ -117,6 +117,8 @@ VERSION = PACKAGE_DATA["version"]
DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "")
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
ENABLE_DB_MIGRATIONS = os.environ.get("ENABLE_DB_MIGRATIONS", "True").lower() == "true"
# Function to parse each section
def parse_section(section):
@ -354,6 +356,8 @@ ENABLE_REALTIME_CHAT_SAVE = (
ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
RAG_SYSTEM_CONTEXT = os.environ.get("RAG_SYSTEM_CONTEXT", "False").lower() == "true"
####################################
# REDIS
####################################
@ -409,6 +413,16 @@ ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
)
####################################
# Admin Account Runtime Creation
####################################
# Optional env vars for creating an admin account on startup
# Useful for headless/automated deployments
WEBUI_ADMIN_EMAIL = os.environ.get("WEBUI_ADMIN_EMAIL", "")
WEBUI_ADMIN_PASSWORD = os.environ.get("WEBUI_ADMIN_PASSWORD", "")
WEBUI_ADMIN_NAME = os.environ.get("WEBUI_ADMIN_NAME", "Admin")
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
)
@ -426,12 +440,14 @@ PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
)
try:
PASSWORD_VALIDATION_REGEX_PATTERN = rf"{PASSWORD_VALIDATION_REGEX_PATTERN}"
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
except Exception as e:
log.error(f"Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}")
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
)
@ -667,6 +683,8 @@ except ValueError:
WEBSOCKET_SERVER_PING_INTERVAL = 25
REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true"
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
if AIOHTTP_CLIENT_TIMEOUT == "":
@ -760,6 +778,16 @@ else:
except Exception:
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
# Whether to apply sigmoid normalization to CrossEncoder reranking scores.
# When enabled (default), scores are normalized to 0-1 range for proper
# relevance threshold behavior with MS MARCO models.
SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
os.environ.get(
"SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION", "True"
).lower()
== "true"
)
####################################
# OFFLINE_MODE
####################################
@ -776,6 +804,11 @@ if OFFLINE_MODE:
####################################
# AUDIT LOGGING
####################################
ENABLE_AUDIT_STDOUT = os.getenv("ENABLE_AUDIT_STDOUT", "False").lower() == "true"
ENABLE_AUDIT_LOGS_FILE = os.getenv("ENABLE_AUDIT_LOGS_FILE", "True").lower() == "true"
# Where to store log file
# Defaults to the DATA_DIR/audit.log. To set AUDIT_LOGS_FILE_PATH you need to
# provide the whole path, like: /app/audit.log

View file

@ -14,11 +14,12 @@ from open_webui.env import (
DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
ENABLE_DB_MIGRATIONS,
)
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.orm import scoped_session, sessionmaker, Session
from sqlalchemy.pool import QueuePool, NullPool
from sqlalchemy.sql.type_api import _T
from typing_extensions import Self
@ -75,7 +76,8 @@ def handle_peewee_migration(DATABASE_URL):
assert db.is_closed(), "Database connection is still open."
handle_peewee_migration(DATABASE_URL)
if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = DATABASE_URL
@ -146,7 +148,7 @@ SessionLocal = sessionmaker(
)
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
Base = declarative_base(metadata=metadata_obj)
Session = scoped_session(SessionLocal)
ScopedSession = scoped_session(SessionLocal)
def get_session():
@ -158,3 +160,12 @@ def get_session():
get_db = contextmanager(get_session)
@contextmanager
def get_db_context(db: Optional[Session] = None):
if isinstance(db, Session):
yield db
else:
with get_db() as session:
yield session

View file

@ -102,7 +102,9 @@ from open_webui.routers.retrieval import (
get_rf,
)
from open_webui.internal.db import Session, engine
from sqlalchemy.orm import Session
from open_webui.internal.db import ScopedSession, engine, get_session
from open_webui.models.functions import Functions
from open_webui.models.models import Models
@ -143,6 +145,7 @@ from open_webui.config import (
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
CODE_INTERPRETER_JUPYTER_TIMEOUT,
ENABLE_MEMORIES,
# Image
AUTOMATIC1111_API_AUTH,
AUTOMATIC1111_BASE_URL,
@ -206,6 +209,7 @@ from open_webui.config import (
PLAYWRIGHT_TIMEOUT,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
FIRECRAWL_TIMEOUT,
WEB_LOADER_ENGINE,
WEB_LOADER_CONCURRENT_REQUESTS,
WEB_LOADER_TIMEOUT,
@ -250,6 +254,7 @@ from open_webui.config import (
RAG_OLLAMA_BASE_URL,
RAG_OLLAMA_API_KEY,
CHUNK_OVERLAP,
CHUNK_MIN_SIZE_TARGET,
CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY,
@ -280,6 +285,7 @@ from open_webui.config import (
MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY,
RAG_TEXT_SPLITTER,
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES,
YOUTUBE_LOADER_LANGUAGE,
@ -295,6 +301,7 @@ from open_webui.config import (
WEB_SEARCH_DOMAIN_FILTER_LIST,
OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
JINA_API_KEY,
JINA_API_BASE_URL,
SEARCHAPI_API_KEY,
SEARCHAPI_ENGINE,
SERPAPI_API_KEY,
@ -306,6 +313,7 @@ from open_webui.config import (
YACY_PASSWORD,
SERPER_API_KEY,
SERPLY_API_KEY,
DDGS_BACKEND,
SERPSTACK_API_KEY,
SERPSTACK_HTTPS,
TAVILY_API_KEY,
@ -358,8 +366,10 @@ from open_webui.config import (
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
API_KEYS_ALLOWED_ENDPOINTS,
ENABLE_FOLDERS,
FOLDER_MAX_FILE_COUNT,
ENABLE_CHANNELS,
ENABLE_NOTES,
ENABLE_USER_STATUS,
ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS,
@ -476,6 +486,10 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
ENABLE_STAR_SESSIONS_MIDDLEWARE,
ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
# Admin Account Runtime Creation
WEBUI_ADMIN_EMAIL,
WEBUI_ADMIN_PASSWORD,
WEBUI_ADMIN_NAME,
)
@ -500,6 +514,7 @@ from open_webui.utils.auth import (
decode_token,
get_admin_user,
get_verified_user,
create_admin_user,
)
from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.oauth import (
@ -578,6 +593,12 @@ async def lifespan(app: FastAPI):
if LICENSE_KEY:
get_license_data(app, LICENSE_KEY)
# Create admin account from env vars if specified and no users exist
if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD:
if create_admin_user(WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD, WEBUI_ADMIN_NAME):
# Disable signup since we now have an admin
app.state.config.ENABLE_SIGNUP = False
# This should be blocking (sync) so functions are not deactivated on first /get_models calls
# when the first user lands on the / route.
log.info("Installing external dependencies of functions and tools...")
@ -774,11 +795,13 @@ app.state.config.BANNERS = WEBUI_BANNERS
app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS
app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_NOTES = ENABLE_NOTES
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS
app.state.config.ENABLE_USER_STATUS = ENABLE_USER_STATUS
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
@ -886,11 +909,17 @@ app.state.config.MINERU_API_TIMEOUT = MINERU_API_TIMEOUT
app.state.config.MINERU_PARAMS = MINERU_PARAMS
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = (
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
)
app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME
app.state.config.CHUNK_SIZE = CHUNK_SIZE
app.state.config.CHUNK_MIN_SIZE_TARGET = CHUNK_MIN_SIZE_TARGET
app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP
app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE
@ -955,12 +984,14 @@ app.state.config.SERPSTACK_API_KEY = SERPSTACK_API_KEY
app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS
app.state.config.SERPER_API_KEY = SERPER_API_KEY
app.state.config.SERPLY_API_KEY = SERPLY_API_KEY
app.state.config.DDGS_BACKEND = DDGS_BACKEND
app.state.config.TAVILY_API_KEY = TAVILY_API_KEY
app.state.config.SEARCHAPI_API_KEY = SEARCHAPI_API_KEY
app.state.config.SEARCHAPI_ENGINE = SEARCHAPI_ENGINE
app.state.config.SERPAPI_API_KEY = SERPAPI_API_KEY
app.state.config.SERPAPI_ENGINE = SERPAPI_ENGINE
app.state.config.JINA_API_KEY = JINA_API_KEY
app.state.config.JINA_API_BASE_URL = JINA_API_BASE_URL
app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT
app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY
app.state.config.EXA_API_KEY = EXA_API_KEY
@ -980,6 +1011,7 @@ app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT
app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL
app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
app.state.config.FIRECRAWL_TIMEOUT = FIRECRAWL_TIMEOUT
app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
app.state.EMBEDDING_FUNCTION = None
@ -1088,6 +1120,7 @@ app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = CODE_INTERPRETER_JUPYTER_TIM
app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE
app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.ENABLE_MEMORIES = ENABLE_MEMORIES
app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL
app.state.config.IMAGE_SIZE = IMAGE_SIZE
@ -1141,7 +1174,6 @@ app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL
app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY
app.state.config.WHISPER_MODEL = WHISPER_MODEL
app.state.config.WHISPER_VAD_FILTER = WHISPER_VAD_FILTER
app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY
app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY
@ -1324,7 +1356,7 @@ app.add_middleware(APIKeyRestrictionMiddleware)
async def commit_session_after_request(request: Request, call_next):
response = await call_next(request)
# log.debug("Commit session after request")
Session.commit()
ScopedSession.commit()
return response
@ -1645,7 +1677,7 @@ async def chat_completion(
)
# Insert chat files from parent message if any
parent_message = metadata.get("parent_message", {})
parent_message = metadata.get("parent_message") or {}
parent_message_files = parent_message.get("files", [])
if parent_message_files:
try:
@ -1902,6 +1934,7 @@ async def get_app_config(request: Request):
{
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
"enable_folders": app.state.config.ENABLE_FOLDERS,
"folder_max_file_count": app.state.config.FOLDER_MAX_FILE_COUNT,
"enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_notes": app.state.config.ENABLE_NOTES,
"enable_web_search": app.state.config.ENABLE_WEB_SEARCH,
@ -1912,10 +1945,12 @@ async def get_app_config(request: Request):
"enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
"enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
"enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS,
"enable_user_status": app.state.config.ENABLE_USER_STATUS,
"enable_admin_export": ENABLE_ADMIN_EXPORT,
"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_memories": app.state.config.ENABLE_MEMORIES,
**(
{
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
@ -2280,8 +2315,13 @@ async def oauth_login(provider: str, request: Request):
# - Email addresses are considered unique, so we fail registration if the email address is already taken
@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)
async def oauth_login_callback(
provider: str,
request: Request,
response: Response,
db: Session = Depends(get_session),
):
return await oauth_manager.handle_callback(request, provider, response, db=db)
@app.get("/manifest.json")
@ -2340,7 +2380,7 @@ async def healthcheck():
@app.get("/health/db")
async def healthcheck_with_db():
Session.execute(text("SELECT 1;")).all()
ScopedSession.execute(text("SELECT 1;")).all()
return {"status": True}

View file

@ -2,7 +2,8 @@ import logging
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import UserModel, UserProfileImageResponse, Users
from pydantic import BaseModel
from sqlalchemy import Boolean, Column, String, Text
@ -87,8 +88,9 @@ class AuthsTable:
profile_image_url: str = "/user.png",
role: str = "pending",
oauth: Optional[dict] = None,
db: Optional[Session] = None,
) -> Optional[UserModel]:
with get_db() as db:
with get_db_context(db) as db:
log.info("insert_new_auth")
id = str(uuid.uuid4())
@ -100,7 +102,7 @@ class AuthsTable:
db.add(result)
user = Users.insert_new_user(
id, name, email, profile_image_url, role, oauth=oauth
id, name, email, profile_image_url, role, oauth=oauth, db=db
)
db.commit()
@ -112,16 +114,16 @@ class AuthsTable:
return None
def authenticate_user(
self, email: str, verify_password: callable
self, email: str, verify_password: callable, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user: {email}")
user = Users.get_user_by_email(email)
user = Users.get_user_by_email(email, db=db)
if not user:
return None
try:
with get_db() as db:
with get_db_context(db) as db:
auth = db.query(Auth).filter_by(id=user.id, active=True).first()
if auth:
if verify_password(auth.password):
@ -133,32 +135,38 @@ class AuthsTable:
except Exception:
return None
def authenticate_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
def authenticate_user_by_api_key(
self, api_key: str, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_api_key: {api_key}")
# if no api_key, return None
if not api_key:
return None
try:
user = Users.get_user_by_api_key(api_key)
user = Users.get_user_by_api_key(api_key, db=db)
return user if user else None
except Exception:
return False
def authenticate_user_by_email(self, email: str) -> Optional[UserModel]:
def authenticate_user_by_email(
self, email: str, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_email: {email}")
try:
with get_db() as db:
with get_db_context(db) as db:
auth = db.query(Auth).filter_by(email=email, active=True).first()
if auth:
user = Users.get_user_by_id(auth.id)
user = Users.get_user_by_id(auth.id, db=db)
return user
except Exception:
return None
def update_user_password_by_id(self, id: str, new_password: str) -> bool:
def update_user_password_by_id(
self, id: str, new_password: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
result = (
db.query(Auth).filter_by(id=id).update({"password": new_password})
)
@ -167,20 +175,22 @@ class AuthsTable:
except Exception:
return False
def update_email_by_id(self, id: str, email: str) -> bool:
def update_email_by_id(
self, id: str, email: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
result = db.query(Auth).filter_by(id=id).update({"email": email})
db.commit()
return True if result == 1 else False
except Exception:
return False
def delete_auth_by_id(self, id: str) -> bool:
def delete_auth_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
# Delete User
result = Users.delete_user_by_id(id)
result = Users.delete_user_by_id(id, db=db)
if result:
db.query(Auth).filter_by(id=id).delete()

View file

@ -1,9 +1,11 @@
import json
import secrets
import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict
@ -244,6 +246,11 @@ class CreateChannelForm(ChannelForm):
type: Optional[str] = None
class ChannelWebhookForm(BaseModel):
name: str
profile_image_url: Optional[str] = None
class ChannelTable:
def _collect_unique_user_ids(
@ -304,9 +311,9 @@ class ChannelTable:
return memberships
def insert_new_channel(
self, form_data: CreateChannelForm, user_id: str
self, form_data: CreateChannelForm, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db() as db:
with get_db_context(db) as db:
channel = ChannelModel(
**{
**form_data.model_dump(),
@ -337,8 +344,8 @@ class ChannelTable:
db.commit()
return channel
def get_channels(self) -> list[ChannelModel]:
with get_db() as db:
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
with get_db_context(db) as db:
channels = db.query(Channel).all()
return [ChannelModel.model_validate(channel) for channel in channels]
@ -384,10 +391,12 @@ class ChannelTable:
return query
def get_channels_by_user_id(self, user_id: str) -> list[ChannelModel]:
with get_db() as db:
def get_channels_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[ChannelModel]:
with get_db_context(db) as db:
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
]
membership_channels = (
@ -421,8 +430,10 @@ class ChannelTable:
all_channels = membership_channels + standard_channels
return [ChannelModel.model_validate(c) for c in all_channels]
def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]:
with get_db() as db:
def get_dm_channel_by_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db_context(db) as db:
# Ensure uniqueness in case a list with duplicates is passed
unique_user_ids = list(set(user_ids))
@ -460,8 +471,9 @@ class ChannelTable:
invited_by: str,
user_ids: Optional[list[str]] = None,
group_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> list[ChannelMemberModel]:
with get_db() as db:
with get_db_context(db) as db:
# 1. Collect all user_ids including groups + inviter
requested_users = self._collect_unique_user_ids(
invited_by, user_ids, group_ids
@ -494,8 +506,9 @@ class ChannelTable:
self,
channel_id: str,
user_ids: list[str],
db: Optional[Session] = None,
) -> int:
with get_db() as db:
with get_db_context(db) as db:
result = (
db.query(ChannelMember)
.filter(
@ -507,8 +520,10 @@ class ChannelTable:
db.commit()
return result # number of rows deleted
def is_user_channel_manager(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
def is_user_channel_manager(
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
# Check if the user is the creator of the channel
# or has a 'manager' role in ChannelMember
channel = db.query(Channel).filter(Channel.id == channel_id).first()
@ -527,9 +542,9 @@ class ChannelTable:
return membership is not None
def join_channel(
self, channel_id: str, user_id: str
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelMemberModel]:
with get_db() as db:
with get_db_context(db) as db:
# Check if the membership already exists
existing_membership = (
db.query(ChannelMember)
@ -565,8 +580,10 @@ class ChannelTable:
db.commit()
return channel_member
def leave_channel(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
def leave_channel(
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@ -587,9 +604,9 @@ class ChannelTable:
return True
def get_member_by_channel_and_user_id(
self, channel_id: str, user_id: str
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelMemberModel]:
with get_db() as db:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@ -600,8 +617,10 @@ class ChannelTable:
)
return ChannelMemberModel.model_validate(membership) if membership else None
def get_members_by_channel_id(self, channel_id: str) -> list[ChannelMemberModel]:
with get_db() as db:
def get_members_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> list[ChannelMemberModel]:
with get_db_context(db) as db:
memberships = (
db.query(ChannelMember)
.filter(ChannelMember.channel_id == channel_id)
@ -612,8 +631,14 @@ class ChannelTable:
for membership in memberships
]
def pin_channel(self, channel_id: str, user_id: str, is_pinned: bool) -> bool:
with get_db() as db:
def pin_channel(
self,
channel_id: str,
user_id: str,
is_pinned: bool,
db: Optional[Session] = None,
) -> bool:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@ -631,8 +656,10 @@ class ChannelTable:
db.commit()
return True
def update_member_last_read_at(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
def update_member_last_read_at(
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@ -651,9 +678,13 @@ class ChannelTable:
return True
def update_member_active_status(
self, channel_id: str, user_id: str, is_active: bool
self,
channel_id: str,
user_id: str,
is_active: bool,
db: Optional[Session] = None,
) -> bool:
with get_db() as db:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@ -671,8 +702,10 @@ class ChannelTable:
db.commit()
return True
def is_user_channel_member(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
def is_user_channel_member(
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@ -683,13 +716,20 @@ class ChannelTable:
)
return membership is not None
def get_channel_by_id(self, id: str) -> Optional[ChannelModel]:
with get_db() as db:
channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None
def get_channel_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChannelModel]:
try:
with get_db_context(db) as db:
channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None
except Exception:
return None
def get_channels_by_file_id(self, file_id: str) -> list[ChannelModel]:
with get_db() as db:
def get_channels_by_file_id(
self, file_id: str, db: Optional[Session] = None
) -> list[ChannelModel]:
with get_db_context(db) as db:
channel_files = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
)
@ -698,9 +738,9 @@ class ChannelTable:
return [ChannelModel.model_validate(channel) for channel in channels]
def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str
self, file_id: str, user_id: str, db: Optional[Session] = None
) -> list[ChannelModel]:
with get_db() as db:
with get_db_context(db) as db:
# 1. Determine which channels have this file
channel_file_rows = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
@ -724,7 +764,9 @@ class ChannelTable:
return []
# Preload user's group membership
user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id)]
user_group_ids = [
g.id for g in Groups.get_groups_by_member_id(user_id, db=db)
]
allowed_channels = []
@ -761,9 +803,9 @@ class ChannelTable:
return allowed_channels
def get_channel_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db() as db:
with get_db_context(db) as db:
# Fetch the channel
channel: Channel = (
db.query(Channel)
@ -799,7 +841,7 @@ class ChannelTable:
# Determine user groups
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
]
# Apply ACL rules
@ -818,9 +860,9 @@ class ChannelTable:
)
def update_channel_by_id(
self, id: str, form_data: ChannelForm
self, id: str, form_data: ChannelForm, db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db() as db:
with get_db_context(db) as db:
channel = db.query(Channel).filter(Channel.id == id).first()
if not channel:
return None
@ -839,9 +881,9 @@ class ChannelTable:
return ChannelModel.model_validate(channel) if channel else None
def add_file_to_channel_by_id(
self, channel_id: str, file_id: str, user_id: str
self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelFileModel]:
with get_db() as db:
with get_db_context(db) as db:
channel_file = ChannelFileModel(
**{
"id": str(uuid.uuid4()),
@ -866,10 +908,14 @@ class ChannelTable:
return None
def set_file_message_id_in_channel_by_id(
self, channel_id: str, file_id: str, message_id: str
self,
channel_id: str,
file_id: str,
message_id: str,
db: Optional[Session] = None,
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
channel_file = (
db.query(ChannelFile)
.filter_by(channel_id=channel_id, file_id=file_id)
@ -886,9 +932,11 @@ class ChannelTable:
except Exception:
return False
def remove_file_from_channel_by_id(self, channel_id: str, file_id: str) -> bool:
def remove_file_from_channel_by_id(
self, channel_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(ChannelFile).filter_by(
channel_id=channel_id, file_id=file_id
).delete()
@ -897,11 +945,115 @@ class ChannelTable:
except Exception:
return False
def delete_channel_by_id(self, id: str):
with get_db() as db:
def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
db.query(Channel).filter(Channel.id == id).delete()
db.commit()
return True
####################
# Webhook Methods
####################
def insert_webhook(
self,
channel_id: str,
user_id: str,
form_data: ChannelWebhookForm,
db: Optional[Session] = None,
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = ChannelWebhookModel(
id=str(uuid.uuid4()),
channel_id=channel_id,
user_id=user_id,
name=form_data.name,
profile_image_url=form_data.profile_image_url,
token=secrets.token_urlsafe(32),
last_used_at=None,
created_at=int(time.time_ns()),
updated_at=int(time.time_ns()),
)
db.add(ChannelWebhook(**webhook.model_dump()))
db.commit()
return webhook
def get_webhooks_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> list[ChannelWebhookModel]:
with get_db_context(db) as db:
webhooks = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.channel_id == channel_id)
.all()
)
return [ChannelWebhookModel.model_validate(w) for w in webhooks]
def get_webhook_by_id(
self, webhook_id: str, db: Optional[Session] = None
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
return ChannelWebhookModel.model_validate(webhook) if webhook else None
def get_webhook_by_id_and_token(
self, webhook_id: str, token: str, db: Optional[Session] = None
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook)
.filter(
ChannelWebhook.id == webhook_id,
ChannelWebhook.token == token,
)
.first()
)
return ChannelWebhookModel.model_validate(webhook) if webhook else None
def update_webhook_by_id(
self,
webhook_id: str,
form_data: ChannelWebhookForm,
db: Optional[Session] = None,
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
if not webhook:
return None
webhook.name = form_data.name
webhook.profile_image_url = form_data.profile_image_url
webhook.updated_at = int(time.time_ns())
db.commit()
return ChannelWebhookModel.model_validate(webhook)
def update_webhook_last_used_at(
self, webhook_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
if not webhook:
return False
webhook.last_used_at = int(time.time_ns())
db.commit()
return True
def delete_webhook_by_id(
self, webhook_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
result = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.id == webhook_id)
.delete()
)
db.commit()
return result > 0
Channels = ChannelTable()

View file

@ -4,7 +4,8 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
@ -210,6 +211,48 @@ class ChatUsageStatsListResponse(BaseModel):
model_config = ConfigDict(extra="allow")
class MessageStats(BaseModel):
id: str
role: str
model: Optional[str] = None
content_length: int
token_count: Optional[int] = None
timestamp: Optional[int] = None
rating: Optional[int] = None # Derived from message.annotation.rating
tags: Optional[list[str]] = None # Derived from message.annotation.tags
class ChatHistoryStats(BaseModel):
messages: dict[str, MessageStats]
currentId: Optional[str] = None
class ChatBody(BaseModel):
history: ChatHistoryStats
class AggregateChatStats(BaseModel):
average_response_time: float
average_user_message_content_length: float
average_assistant_message_content_length: float
models: dict[str, int]
message_count: int
history_models: dict[str, int]
history_message_count: int
history_user_message_count: int
history_assistant_message_count: int
class ChatStatsExport(BaseModel):
id: str
user_id: str
created_at: int
updated_at: int
tags: list[str] = []
stats: AggregateChatStats
chat: ChatBody
class ChatTable:
def _clean_null_bytes(self, obj):
"""Recursively remove null bytes from strings in dict/list structures."""
@ -238,8 +281,10 @@ class ChatTable:
return changed
def insert_new_chat(self, user_id: str, form_data: ChatForm) -> Optional[ChatModel]:
with get_db() as db:
def insert_new_chat(
self, user_id: str, form_data: ChatForm, db: Optional[Session] = None
) -> Optional[ChatModel]:
with get_db_context(db) as db:
id = str(uuid.uuid4())
chat = ChatModel(
**{
@ -289,9 +334,12 @@ class ChatTable:
return chat
def import_chats(
self, user_id: str, chat_import_forms: list[ChatImportForm]
self,
user_id: str,
chat_import_forms: list[ChatImportForm],
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db() as db:
with get_db_context(db) as db:
chats = []
for form_data in chat_import_forms:
@ -302,9 +350,11 @@ class ChatTable:
db.commit()
return [ChatModel.model_validate(chat) for chat in chats]
def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]:
def update_chat_by_id(
self, id: str, chat: dict, db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
chat_item = db.get(Chat, id)
chat_item.chat = self._clean_null_bytes(chat)
chat_item.title = (
@ -423,31 +473,37 @@ class ChatTable:
def add_message_files_by_id_and_message_id(
self, id: str, message_id: str, files: list[dict]
) -> list[dict]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
with get_db_context() as db:
chat = self.get_chat_by_id(id, db=db)
if chat is None:
return None
chat = chat.chat
history = chat.get("history", {})
chat = chat.chat
history = chat.get("history", {})
message_files = []
message_files = []
if message_id in history.get("messages", {}):
message_files = history["messages"][message_id].get("files", [])
message_files = message_files + files
history["messages"][message_id]["files"] = message_files
if message_id in history.get("messages", {}):
message_files = history["messages"][message_id].get("files", [])
message_files = message_files + files
history["messages"][message_id]["files"] = message_files
chat["history"] = history
self.update_chat_by_id(id, chat)
return message_files
chat["history"] = history
self.update_chat_by_id(id, chat, db=db)
return message_files
def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]:
with get_db() as db:
def insert_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
with get_db_context(db) as db:
# Get the existing chat to share
chat = db.get(Chat, chat_id)
# Check if chat exists
if not chat:
return None
# Check if the chat is already shared
if chat.share_id:
return self.get_chat_by_id_and_user_id(chat.share_id, "shared")
return self.get_chat_by_id_and_user_id(chat.share_id, "shared", db=db)
# Create a new chat with the same data, but with a new ID
shared_chat = ChatModel(
**{
@ -476,16 +532,18 @@ class ChatTable:
db.commit()
return shared_chat if (shared_result and result) else None
def update_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]:
def update_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.get(Chat, chat_id)
shared_chat = (
db.query(Chat).filter_by(user_id=f"shared-{chat_id}").first()
)
if shared_chat is None:
return self.insert_shared_chat_by_chat_id(chat_id)
return self.insert_shared_chat_by_chat_id(chat_id, db=db)
shared_chat.title = chat.title
shared_chat.chat = chat.chat
@ -500,9 +558,11 @@ class ChatTable:
except Exception:
return None
def delete_shared_chat_by_chat_id(self, chat_id: str) -> bool:
def delete_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete()
db.commit()
@ -510,9 +570,11 @@ class ChatTable:
except Exception:
return False
def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
def unarchive_all_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
db.commit()
return True
@ -520,10 +582,10 @@ class ChatTable:
return False
def update_chat_share_id_by_id(
self, id: str, share_id: Optional[str]
self, id: str, share_id: Optional[str], db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.get(Chat, id)
chat.share_id = share_id
db.commit()
@ -532,9 +594,11 @@ class ChatTable:
except Exception:
return None
def toggle_chat_pinned_by_id(self, id: str) -> Optional[ChatModel]:
def toggle_chat_pinned_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.get(Chat, id)
chat.pinned = not chat.pinned
chat.updated_at = int(time.time())
@ -544,9 +608,11 @@ class ChatTable:
except Exception:
return None
def toggle_chat_archive_by_id(self, id: str) -> Optional[ChatModel]:
def toggle_chat_archive_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.get(Chat, id)
chat.archived = not chat.archived
chat.folder_id = None
@ -557,9 +623,11 @@ class ChatTable:
except Exception:
return None
def archive_all_chats_by_user_id(self, user_id: str) -> bool:
def archive_all_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=user_id).update({"archived": True})
db.commit()
return True
@ -572,9 +640,10 @@ class ChatTable:
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id, archived=True)
if filter:
@ -613,8 +682,9 @@ class ChatTable:
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id)
if not include_archived:
query = query.filter_by(archived=False)
@ -653,8 +723,9 @@ class ChatTable:
include_pinned: bool = False,
skip: Optional[int] = None,
limit: Optional[int] = None,
db: Optional[Session] = None,
) -> list[ChatTitleIdResponse]:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id)
if not include_folders:
@ -691,9 +762,13 @@ class ChatTable:
]
def get_chat_list_by_chat_ids(
self, chat_ids: list[str], skip: int = 0, limit: int = 50
self,
chat_ids: list[str],
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db() as db:
with get_db_context(db) as db:
all_chats = (
db.query(Chat)
.filter(Chat.id.in_(chat_ids))
@ -703,9 +778,11 @@ class ChatTable:
)
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chat_by_id(self, id: str) -> Optional[ChatModel]:
def get_chat_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
chat_item = db.get(Chat, id)
if chat_item is None:
return None
@ -718,30 +795,36 @@ class ChatTable:
except Exception:
return None
def get_chat_by_share_id(self, id: str) -> Optional[ChatModel]:
def get_chat_by_share_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
# it is possible that the shared link was deleted. hence,
# we check if the chat is still shared by checking if a chat with the share_id exists
chat = db.query(Chat).filter_by(share_id=id).first()
if chat:
return self.get_chat_by_id(id)
return self.get_chat_by_id(id, db=db)
else:
return None
except Exception:
return None
def get_chat_by_id_and_user_id(self, id: str, user_id: str) -> Optional[ChatModel]:
def get_chat_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.query(Chat).filter_by(id=id, user_id=user_id).first()
return ChatModel.model_validate(chat)
except Exception:
return None
def get_chats(self, skip: int = 0, limit: int = 50) -> list[ChatModel]:
with get_db() as db:
def get_chats(
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db_context(db) as db:
all_chats = (
db.query(Chat)
# .limit(limit).offset(skip)
@ -750,14 +833,34 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_user_id(
self, user_id: str, skip: Optional[int] = None, limit: Optional[int] = None
self,
user_id: str,
filter: Optional[dict] = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
db: Optional[Session] = None,
) -> ChatListResponse:
with get_db() as db:
query = (
db.query(Chat)
.filter_by(user_id=user_id)
.order_by(Chat.updated_at.desc())
)
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id)
if filter:
if filter.get("updated_at"):
query = query.filter(Chat.updated_at > filter.get("updated_at"))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction:
if hasattr(Chat, order_by):
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
query = query.order_by(Chat.updated_at.desc())
else:
query = query.order_by(Chat.updated_at.desc())
total = query.count()
@ -775,8 +878,10 @@ class ChatTable:
}
)
def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]:
with get_db() as db:
def get_pinned_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db_context(db) as db:
all_chats = (
db.query(Chat)
.filter_by(user_id=user_id, pinned=True, archived=False)
@ -784,8 +889,10 @@ class ChatTable:
)
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_archived_chats_by_user_id(self, user_id: str) -> list[ChatModel]:
with get_db() as db:
def get_archived_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db_context(db) as db:
all_chats = (
db.query(Chat)
.filter_by(user_id=user_id, archived=True)
@ -800,6 +907,7 @@ class ChatTable:
include_archived: bool = False,
skip: int = 0,
limit: int = 60,
db: Optional[Session] = None,
) -> list[ChatModel]:
"""
Filters chats based on a search query using Python, allowing pagination using skip and limit.
@ -808,7 +916,7 @@ class ChatTable:
if not search_text:
return self.get_chat_list_by_user_id(
user_id, include_archived, filter={}, skip=skip, limit=limit
user_id, include_archived, filter={}, skip=skip, limit=limit, db=db
)
search_text_words = search_text.split(" ")
@ -863,7 +971,7 @@ class ChatTable:
search_text = " ".join(search_text_words)
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Chat).filter(Chat.user_id == user_id)
if is_archived is not None:
@ -1004,9 +1112,14 @@ 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, skip: int = 0, limit: int = 60
self,
folder_id: str,
user_id: str,
skip: int = 0,
limit: int = 60,
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
query = query.filter_by(archived=False)
@ -1022,9 +1135,9 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str
self, folder_ids: list[str], user_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Chat).filter(
Chat.folder_id.in_(folder_ids), Chat.user_id == user_id
)
@ -1037,10 +1150,10 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def update_chat_folder_id_by_id_and_user_id(
self, id: str, user_id: str, folder_id: str
self, id: str, user_id: str, folder_id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.get(Chat, id)
chat.folder_id = folder_id
chat.updated_at = int(time.time())
@ -1051,16 +1164,23 @@ class ChatTable:
except Exception:
return None
def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str) -> list[TagModel]:
with get_db() as db:
def get_chat_tags_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> list[TagModel]:
with get_db_context(db) as db:
chat = db.get(Chat, id)
tags = chat.meta.get("tags", [])
return [Tags.get_tag_by_name_and_user_id(tag, user_id) for tag in tags]
def get_chat_list_by_user_id_and_tag_name(
self, user_id: str, tag_name: str, skip: int = 0, limit: int = 50
self,
user_id: str,
tag_name: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id)
tag_id = tag_name.replace(" ", "_").lower()
@ -1089,13 +1209,13 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def add_chat_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str
self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
tag = Tags.get_tag_by_name_and_user_id(tag_name, user_id)
if tag is None:
tag = Tags.insert_new_tag(tag_name, user_id)
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.get(Chat, id)
tag_id = tag.id
@ -1111,8 +1231,10 @@ class ChatTable:
except Exception:
return None
def count_chats_by_tag_name_and_user_id(self, tag_name: str, user_id: str) -> int:
with get_db() as db: # Assuming `get_db()` returns a session object
def count_chats_by_tag_name_and_user_id(
self, tag_name: str, user_id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db: # Assuming `get_db()` returns a session object
query = db.query(Chat).filter_by(user_id=user_id, archived=False)
# Normalize the tag_name for consistency
@ -1147,8 +1269,10 @@ 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:
def count_chats_by_folder_id_and_user_id(
self, folder_id: str, user_id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id)
query = query.filter_by(folder_id=folder_id)
@ -1158,10 +1282,10 @@ class ChatTable:
return count
def delete_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str
self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.get(Chat, id)
tags = chat.meta.get("tags", [])
tag_id = tag_name.replace(" ", "_").lower()
@ -1176,9 +1300,11 @@ class ChatTable:
except Exception:
return False
def delete_all_tags_by_id_and_user_id(self, id: str, user_id: str) -> bool:
def delete_all_tags_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
chat = db.get(Chat, id)
chat.meta = {
**chat.meta,
@ -1190,30 +1316,34 @@ class ChatTable:
except Exception:
return False
def delete_chat_by_id(self, id: str) -> bool:
def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Chat).filter_by(id=id).delete()
db.commit()
return True and self.delete_shared_chat_by_chat_id(id)
return True and self.delete_shared_chat_by_chat_id(id, db=db)
except Exception:
return False
def delete_chat_by_id_and_user_id(self, id: str, user_id: str) -> bool:
def delete_chat_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Chat).filter_by(id=id, user_id=user_id).delete()
db.commit()
return True and self.delete_shared_chat_by_chat_id(id)
return True and self.delete_shared_chat_by_chat_id(id, db=db)
except Exception:
return False
def delete_chats_by_user_id(self, user_id: str) -> bool:
def delete_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
self.delete_shared_chats_by_user_id(user_id)
with get_db_context(db) as db:
self.delete_shared_chats_by_user_id(user_id, db=db)
db.query(Chat).filter_by(user_id=user_id).delete()
db.commit()
@ -1223,10 +1353,10 @@ class ChatTable:
return False
def delete_chats_by_user_id_and_folder_id(
self, user_id: str, folder_id: str
self, user_id: str, folder_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete()
db.commit()
@ -1235,10 +1365,14 @@ class ChatTable:
return False
def move_chats_by_user_id_and_folder_id(
self, user_id: str, folder_id: str, new_folder_id: Optional[str]
self,
user_id: str,
folder_id: str,
new_folder_id: Optional[str],
db: Optional[Session] = None,
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update(
{"folder_id": new_folder_id}
)
@ -1248,9 +1382,11 @@ class ChatTable:
except Exception:
return False
def delete_shared_chats_by_user_id(self, user_id: str) -> bool:
def delete_shared_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
chats_by_user = db.query(Chat).filter_by(user_id=user_id).all()
shared_chat_ids = [f"shared-{chat.id}" for chat in chats_by_user]
@ -1262,7 +1398,12 @@ class ChatTable:
return False
def insert_chat_files(
self, chat_id: str, message_id: str, file_ids: list[str], user_id: str
self,
chat_id: str,
message_id: str,
file_ids: list[str],
user_id: str,
db: Optional[Session] = None,
) -> Optional[list[ChatFileModel]]:
if not file_ids:
return None
@ -1270,7 +1411,7 @@ class ChatTable:
chat_message_file_ids = [
item.id
for item in self.get_chat_files_by_chat_id_and_message_id(
chat_id, message_id
chat_id, message_id, db=db
)
]
# Remove duplicates and existing file_ids
@ -1287,7 +1428,7 @@ class ChatTable:
return None
try:
with get_db() as db:
with get_db_context(db) as db:
now = int(time.time())
chat_files = [
@ -1315,9 +1456,9 @@ class ChatTable:
return None
def get_chat_files_by_chat_id_and_message_id(
self, chat_id: str, message_id: str
self, chat_id: str, message_id: str, db: Optional[Session] = None
) -> list[ChatFileModel]:
with get_db() as db:
with get_db_context(db) as db:
all_chat_files = (
db.query(ChatFile)
.filter_by(chat_id=chat_id, message_id=message_id)
@ -1328,17 +1469,21 @@ class ChatTable:
ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files
]
def delete_chat_file(self, chat_id: str, file_id: str) -> bool:
def delete_chat_file(
self, chat_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(ChatFile).filter_by(chat_id=chat_id, file_id=file_id).delete()
db.commit()
return True
except Exception:
return False
def get_shared_chats_by_file_id(self, file_id: str) -> list[ChatModel]:
with get_db() as db:
def get_shared_chats_by_file_id(
self, file_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db_context(db) as db:
# Join Chat and ChatFile tables to get shared chats associated with the file_id
all_chats = (
db.query(Chat)

View file

@ -3,7 +3,8 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import User
from pydantic import BaseModel, ConfigDict
@ -67,6 +68,13 @@ class FeedbackIdResponse(BaseModel):
updated_at: int
class LeaderboardFeedbackData(BaseModel):
"""Minimal feedback data for leaderboard computation (excludes snapshot/meta)."""
id: str
data: Optional[dict] = None
class RatingData(BaseModel):
rating: Optional[str | int] = None
model_id: Optional[str] = None
@ -119,11 +127,22 @@ class FeedbackListResponse(BaseModel):
total: int
class ModelHistoryEntry(BaseModel):
date: str
won: int
lost: int
class ModelHistoryResponse(BaseModel):
model_id: str
history: list[ModelHistoryEntry]
class FeedbackTable:
def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm
self, user_id: str, form_data: FeedbackForm, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
with get_db() as db:
with get_db_context(db) as db:
id = str(uuid.uuid4())
feedback = FeedbackModel(
**{
@ -148,9 +167,11 @@ class FeedbackTable:
log.exception(f"Error creating a new feedback: {e}")
return None
def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]:
def get_feedback_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback:
return None
@ -159,10 +180,10 @@ class FeedbackTable:
return None
def get_feedback_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback:
return None
@ -171,9 +192,13 @@ class FeedbackTable:
return None
def get_feedback_items(
self, filter: dict = {}, skip: int = 0, limit: int = 30
self,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> FeedbackListResponse:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
if filter:
@ -234,8 +259,8 @@ class FeedbackTable:
return FeedbackListResponse(items=feedbacks, total=total)
def get_all_feedbacks(self) -> list[FeedbackModel]:
with get_db() as db:
def get_all_feedbacks(self, db: Optional[Session] = None) -> list[FeedbackModel]:
with get_db_context(db) as db:
return [
FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback)
@ -243,8 +268,110 @@ class FeedbackTable:
.all()
]
def get_feedbacks_by_type(self, type: str) -> list[FeedbackModel]:
with get_db() as db:
def get_all_feedback_ids(
self, db: Optional[Session] = None
) -> list[FeedbackIdResponse]:
with get_db_context(db) as db:
return [
FeedbackIdResponse(
id=row.id,
user_id=row.user_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in db.query(
Feedback.id,
Feedback.user_id,
Feedback.created_at,
Feedback.updated_at,
)
.order_by(Feedback.updated_at.desc())
.all()
]
def get_feedbacks_for_leaderboard(
self, db: Optional[Session] = None
) -> list[LeaderboardFeedbackData]:
"""Fetch only id and data for leaderboard computation (excludes snapshot/meta)."""
with get_db_context(db) as db:
return [
LeaderboardFeedbackData(id=row.id, data=row.data)
for row in db.query(Feedback.id, Feedback.data).all()
]
def get_model_evaluation_history(
self, model_id: str, days: int = 30, db: Optional[Session] = None
) -> list[ModelHistoryEntry]:
"""
Get daily wins/losses for a specific model over the past N days.
If days=0, returns all time data starting from first feedback.
Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...]
"""
from datetime import datetime, timedelta
from collections import defaultdict
with get_db_context(db) as db:
if days == 0:
# All time - no cutoff
rows = db.query(Feedback.created_at, Feedback.data).all()
else:
cutoff = int(time.time()) - (days * 86400)
rows = (
db.query(Feedback.created_at, Feedback.data)
.filter(Feedback.created_at >= cutoff)
.all()
)
daily_counts = defaultdict(lambda: {"won": 0, "lost": 0})
first_date = None
for created_at, data in rows:
if not data:
continue
if data.get("model_id") != model_id:
continue
rating_str = str(data.get("rating", ""))
if rating_str not in ("1", "-1"):
continue
date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d")
if rating_str == "1":
daily_counts[date_str]["won"] += 1
else:
daily_counts[date_str]["lost"] += 1
# Track first date for this model
if first_date is None or date_str < first_date:
first_date = date_str
# Generate date range
result = []
today = datetime.now().date()
if days == 0 and first_date:
# All time: start from first feedback date
start_date = datetime.strptime(first_date, "%Y-%m-%d").date()
num_days = (today - start_date).days + 1
else:
# Fixed range
num_days = days
start_date = today - timedelta(days=days - 1)
for i in range(num_days):
d = start_date + timedelta(days=i)
date_str = d.strftime("%Y-%m-%d")
counts = daily_counts.get(date_str, {"won": 0, "lost": 0})
result.append(
ModelHistoryEntry(date=date_str, won=counts["won"], lost=counts["lost"])
)
return result
def get_feedbacks_by_type(
self, type: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
with get_db_context(db) as db:
return [
FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback)
@ -253,8 +380,10 @@ class FeedbackTable:
.all()
]
def get_feedbacks_by_user_id(self, user_id: str) -> list[FeedbackModel]:
with get_db() as db:
def get_feedbacks_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
with get_db_context(db) as db:
return [
FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback)
@ -264,9 +393,9 @@ class FeedbackTable:
]
def update_feedback_by_id(
self, id: str, form_data: FeedbackForm
self, id: str, form_data: FeedbackForm, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
with get_db() as db:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback:
return None
@ -284,9 +413,13 @@ class FeedbackTable:
return FeedbackModel.model_validate(feedback)
def update_feedback_by_id_and_user_id(
self, id: str, user_id: str, form_data: FeedbackForm
self,
id: str,
user_id: str,
form_data: FeedbackForm,
db: Optional[Session] = None,
) -> Optional[FeedbackModel]:
with get_db() as db:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback:
return None
@ -303,8 +436,8 @@ class FeedbackTable:
db.commit()
return FeedbackModel.model_validate(feedback)
def delete_feedback_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_feedback_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback:
return False
@ -312,8 +445,10 @@ class FeedbackTable:
db.commit()
return True
def delete_feedback_by_id_and_user_id(self, id: str, user_id: str) -> bool:
with get_db() as db:
def delete_feedback_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback:
return False
@ -321,8 +456,10 @@ class FeedbackTable:
db.commit()
return True
def delete_feedbacks_by_user_id(self, user_id: str) -> bool:
with get_db() as db:
def delete_feedbacks_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
feedbacks = db.query(Feedback).filter_by(user_id=user_id).all()
if not feedbacks:
return False
@ -331,8 +468,8 @@ class FeedbackTable:
db.commit()
return True
def delete_all_feedbacks(self) -> bool:
with get_db() as db:
def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
feedbacks = db.query(Feedback).all()
if not feedbacks:
return False

View file

@ -2,7 +2,8 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
@ -108,8 +109,10 @@ class FileListResponse(BaseModel):
class FilesTable:
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
with get_db() as db:
def insert_new_file(
self, user_id: str, form_data: FileForm, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
file = FileModel(
**{
**form_data.model_dump(),
@ -132,16 +135,23 @@ class FilesTable:
log.exception(f"Error inserting a new file: {e}")
return None
def get_file_by_id(self, id: str) -> Optional[FileModel]:
with get_db() as db:
try:
file = db.get(File, id)
return FileModel.model_validate(file)
except Exception:
return None
def get_file_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FileModel]:
try:
with get_db_context(db) as db:
try:
file = db.get(File, id)
return FileModel.model_validate(file)
except Exception:
return None
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:
def get_file_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id, user_id=user_id).first()
if file:
@ -151,8 +161,10 @@ class FilesTable:
except Exception:
return None
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
with get_db() as db:
def get_file_metadata_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FileMetadataResponse]:
with get_db_context(db) as db:
try:
file = db.get(File, id)
return FileMetadataResponse(
@ -165,12 +177,14 @@ class FilesTable:
except Exception:
return None
def get_files(self) -> list[FileModel]:
with get_db() as db:
def get_files(self, db: Optional[Session] = None) -> list[FileModel]:
with get_db_context(db) as db:
return [FileModel.model_validate(file) for file in db.query(File).all()]
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
file = self.get_file_by_id(id)
def check_access_by_user_id(
self, id, user_id, permission="write", db: Optional[Session] = None
) -> bool:
file = self.get_file_by_id(id, db=db)
if not file:
return False
if file.user_id == user_id:
@ -178,8 +192,10 @@ class FilesTable:
# Implement additional access control logic here as needed
return False
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
with get_db() as db:
def get_files_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[FileModel]:
with get_db_context(db) as db:
return [
FileModel.model_validate(file)
for file in db.query(File)
@ -188,8 +204,10 @@ class FilesTable:
.all()
]
def get_file_metadatas_by_ids(self, ids: list[str]) -> list[FileMetadataResponse]:
with get_db() as db:
def get_file_metadatas_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[FileMetadataResponse]:
with get_db_context(db) as db:
return [
FileMetadataResponse(
id=file.id,
@ -206,17 +224,81 @@ class FilesTable:
.all()
]
def get_files_by_user_id(self, user_id: str) -> list[FileModel]:
with get_db() as db:
def get_files_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[FileModel]:
with get_db_context(db) as db:
return [
FileModel.model_validate(file)
for file in db.query(File).filter_by(user_id=user_id).all()
]
@staticmethod
def _glob_to_like_pattern(glob: str) -> str:
"""
Convert a glob/fnmatch pattern to a SQL LIKE pattern.
Escapes SQL special characters and converts glob wildcards:
- `*` becomes `%` (match any sequence of characters)
- `?` becomes `_` (match exactly one character)
Args:
glob: A glob pattern (e.g., "*.txt", "file?.doc")
Returns:
A SQL LIKE compatible pattern with proper escaping.
"""
# Escape SQL special characters first, then convert glob wildcards
pattern = glob.replace("\\", "\\\\")
pattern = pattern.replace("%", "\\%")
pattern = pattern.replace("_", "\\_")
pattern = pattern.replace("*", "%")
pattern = pattern.replace("?", "_")
return pattern
def search_files(
self,
user_id: Optional[str] = None,
filename: str = "*",
skip: int = 0,
limit: int = 100,
db: Optional[Session] = None,
) -> list[FileModel]:
"""
Search files with glob pattern matching, optional user filter, and pagination.
Args:
user_id: Filter by user ID. If None, returns files for all users.
filename: Glob pattern to match filenames (e.g., "*.txt"). Default "*" matches all.
skip: Number of results to skip for pagination.
limit: Maximum number of results to return.
db: Optional database session.
Returns:
List of matching FileModel objects, ordered by updated_at descending.
"""
with get_db_context(db) as db:
query = db.query(File)
if user_id:
query = query.filter_by(user_id=user_id)
pattern = self._glob_to_like_pattern(filename)
if pattern != "%":
query = query.filter(File.filename.ilike(pattern, escape="\\"))
return [
FileModel.model_validate(file)
for file in query.order_by(File.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
]
def update_file_by_id(
self, id: str, form_data: FileUpdateForm
self, id: str, form_data: FileUpdateForm, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db() as db:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
@ -236,8 +318,10 @@ class FilesTable:
log.exception(f"Error updating file completely by id: {e}")
return None
def update_file_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]:
with get_db() as db:
def update_file_hash_by_id(
self, id: str, hash: Optional[str], db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
file.hash = hash
@ -248,8 +332,10 @@ class FilesTable:
except Exception:
return None
def update_file_data_by_id(self, id: str, data: dict) -> Optional[FileModel]:
with get_db() as db:
def update_file_data_by_id(
self, id: str, data: dict, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
file.data = {**(file.data if file.data else {}), **data}
@ -260,8 +346,10 @@ class FilesTable:
return None
def update_file_metadata_by_id(self, id: str, meta: dict) -> Optional[FileModel]:
with get_db() as db:
def update_file_metadata_by_id(
self, id: str, meta: dict, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
file.meta = {**(file.meta if file.meta else {}), **meta}
@ -271,8 +359,10 @@ class FilesTable:
except Exception:
return None
def delete_file_by_id(self, id: str) -> bool:
with get_db() as db:
return False
def delete_file_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(File).filter_by(id=id).delete()
db.commit()
@ -281,8 +371,8 @@ class FilesTable:
except Exception:
return False
def delete_all_files(self) -> bool:
with get_db() as db:
def delete_all_files(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(File).delete()
db.commit()

View file

@ -7,8 +7,9 @@ import re
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
log = logging.getLogger(__name__)
@ -83,9 +84,13 @@ class FolderUpdateForm(BaseModel):
class FolderTable:
def insert_new_folder(
self, user_id: str, form_data: FolderForm, parent_id: Optional[str] = None
self,
user_id: str,
form_data: FolderForm,
parent_id: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[FolderModel]:
with get_db() as db:
with get_db_context(db) as db:
id = str(uuid.uuid4())
folder = FolderModel(
**{
@ -111,10 +116,10 @@ class FolderTable:
return None
def get_folder_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
@ -125,15 +130,15 @@ class FolderTable:
return None
def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[list[FolderModel]]:
try:
with get_db() as db:
with get_db_context(db) as db:
folders = []
def get_children(folder):
children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id
folder.id, user_id, db=db
)
for child in children:
get_children(child)
@ -148,18 +153,24 @@ class FolderTable:
except Exception:
return None
def get_folders_by_user_id(self, user_id: str) -> list[FolderModel]:
with get_db() as db:
def get_folders_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[FolderModel]:
with get_db_context(db) as db:
return [
FolderModel.model_validate(folder)
for folder in db.query(Folder).filter_by(user_id=user_id).all()
]
def get_folder_by_parent_id_and_user_id_and_name(
self, parent_id: Optional[str], user_id: str, name: str
self,
parent_id: Optional[str],
user_id: str,
name: str,
db: Optional[Session] = None,
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
# Check if folder exists
folder = (
db.query(Folder)
@ -177,9 +188,9 @@ class FolderTable:
return None
def get_folders_by_parent_id_and_user_id(
self, parent_id: Optional[str], user_id: str
self, parent_id: Optional[str], user_id: str, db: Optional[Session] = None
) -> list[FolderModel]:
with get_db() as db:
with get_db_context(db) as db:
return [
FolderModel.model_validate(folder)
for folder in db.query(Folder)
@ -192,9 +203,10 @@ class FolderTable:
id: str,
user_id: str,
parent_id: str,
db: Optional[Session] = None,
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
@ -211,10 +223,14 @@ class FolderTable:
return
def update_folder_by_id_and_user_id(
self, id: str, user_id: str, form_data: FolderUpdateForm
self,
id: str,
user_id: str,
form_data: FolderUpdateForm,
db: Optional[Session] = None,
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
@ -257,10 +273,10 @@ class FolderTable:
return
def update_folder_is_expanded_by_id_and_user_id(
self, id: str, user_id: str, is_expanded: bool
self, id: str, user_id: str, is_expanded: bool, db: Optional[Session] = None
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
@ -276,10 +292,12 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]:
def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> list[str]:
try:
folder_ids = []
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return folder_ids
@ -289,7 +307,7 @@ class FolderTable:
# Delete all children folders
def delete_children(folder):
folder_children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id
folder.id, user_id, db=db
)
for folder_child in folder_children:
@ -314,7 +332,7 @@ class FolderTable:
return name.strip().lower()
def search_folders_by_names(
self, user_id: str, queries: list[str]
self, user_id: str, queries: list[str], db: Optional[Session] = None
) -> list[FolderModel]:
"""
Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
@ -324,7 +342,7 @@ class FolderTable:
return []
results = {}
with get_db() as db:
with get_db_context(db) as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
if self.normalize_folder_name(folder.name) in normalized_queries:
@ -332,7 +350,7 @@ class FolderTable:
# get children folders
children = self.get_children_folders_by_id_and_user_id(
folder.id, user_id
folder.id, user_id, db=db
)
for child in children:
results[child.id] = child
@ -345,14 +363,14 @@ class FolderTable:
return results
def search_folders_by_name_contains(
self, user_id: str, query: str
self, user_id: str, query: str, db: Optional[Session] = None
) -> list[FolderModel]:
"""
Partial match: normalized name contains (as substring) the normalized query.
"""
normalized_query = self.normalize_folder_name(query)
results = []
with get_db() as db:
with get_db_context(db) as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
norm_name = self.normalize_folder_name(folder.name)

View file

@ -2,7 +2,8 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
@ -103,7 +104,11 @@ class FunctionValves(BaseModel):
class FunctionsTable:
def insert_new_function(
self, user_id: str, type: str, form_data: FunctionForm
self,
user_id: str,
type: str,
form_data: FunctionForm,
db: Optional[Session] = None,
) -> Optional[FunctionModel]:
function = FunctionModel(
**{
@ -116,7 +121,7 @@ class FunctionsTable:
)
try:
with get_db() as db:
with get_db_context(db) as db:
result = Function(**function.model_dump())
db.add(result)
db.commit()
@ -130,11 +135,14 @@ class FunctionsTable:
return None
def sync_functions(
self, user_id: str, functions: list[FunctionWithValvesModel]
self,
user_id: str,
functions: list[FunctionWithValvesModel],
db: Optional[Session] = None,
) -> list[FunctionWithValvesModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try:
with get_db() as db:
with get_db_context(db) as db:
# Get existing functions
existing_functions = db.query(Function).all()
existing_ids = {func.id for func in existing_functions}
@ -177,18 +185,20 @@ class FunctionsTable:
log.exception(f"Error syncing functions for user {user_id}: {e}")
return []
def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
def get_function_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FunctionModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
function = db.get(Function, id)
return FunctionModel.model_validate(function)
except Exception:
return None
def get_functions(
self, active_only=False, include_valves=False
self, active_only=False, include_valves=False, db: Optional[Session] = None
) -> list[FunctionModel | FunctionWithValvesModel]:
with get_db() as db:
with get_db_context(db) as db:
if active_only:
functions = db.query(Function).filter_by(is_active=True).all()
@ -205,12 +215,14 @@ class FunctionsTable:
FunctionModel.model_validate(function) for function in functions
]
def get_function_list(self) -> list[FunctionUserResponse]:
with get_db() as db:
def get_function_list(
self, db: Optional[Session] = None
) -> list[FunctionUserResponse]:
with get_db_context(db) as db:
functions = db.query(Function).order_by(Function.updated_at.desc()).all()
user_ids = list(set(func.user_id for func in functions))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
return [
@ -228,9 +240,9 @@ class FunctionsTable:
]
def get_functions_by_type(
self, type: str, active_only=False
self, type: str, active_only=False, db: Optional[Session] = None
) -> list[FunctionModel]:
with get_db() as db:
with get_db_context(db) as db:
if active_only:
return [
FunctionModel.model_validate(function)
@ -244,8 +256,10 @@ class FunctionsTable:
for function in db.query(Function).filter_by(type=type).all()
]
def get_global_filter_functions(self) -> list[FunctionModel]:
with get_db() as db:
def get_global_filter_functions(
self, db: Optional[Session] = None
) -> list[FunctionModel]:
with get_db_context(db) as db:
return [
FunctionModel.model_validate(function)
for function in db.query(Function)
@ -253,8 +267,10 @@ class FunctionsTable:
.all()
]
def get_global_action_functions(self) -> list[FunctionModel]:
with get_db() as db:
def get_global_action_functions(
self, db: Optional[Session] = None
) -> list[FunctionModel]:
with get_db_context(db) as db:
return [
FunctionModel.model_validate(function)
for function in db.query(Function)
@ -262,8 +278,10 @@ class FunctionsTable:
.all()
]
def get_function_valves_by_id(self, id: str) -> Optional[dict]:
with get_db() as db:
def get_function_valves_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[dict]:
with get_db_context(db) as db:
try:
function = db.get(Function, id)
return function.valves if function.valves else {}
@ -272,23 +290,23 @@ class FunctionsTable:
return None
def update_function_valves_by_id(
self, id: str, valves: dict
self, id: str, valves: dict, db: Optional[Session] = None
) -> Optional[FunctionValves]:
with get_db() as db:
with get_db_context(db) as db:
try:
function = db.get(Function, id)
function.valves = valves
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id)
return self.get_function_by_id(id, db=db)
except Exception:
return None
def update_function_metadata_by_id(
self, id: str, metadata: dict
self, id: str, metadata: dict, db: Optional[Session] = None
) -> Optional[FunctionModel]:
with get_db() as db:
with get_db_context(db) as db:
try:
function = db.get(Function, id)
@ -301,7 +319,7 @@ class FunctionsTable:
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id)
return self.get_function_by_id(id, db=db)
else:
return None
except Exception as e:
@ -309,10 +327,10 @@ class FunctionsTable:
return None
def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "functions" and "valves" settings
@ -327,10 +345,10 @@ class FunctionsTable:
return None
def update_user_valves_by_id_and_user_id(
self, id: str, user_id: str, valves: dict
self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "functions" and "valves" settings
@ -342,7 +360,7 @@ class FunctionsTable:
user_settings["functions"]["valves"][id] = valves
# Update the user settings in the database
Users.update_user_by_id(user_id, {"settings": user_settings})
Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
return user_settings["functions"]["valves"][id]
except Exception as e:
@ -351,8 +369,10 @@ class FunctionsTable:
)
return None
def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]:
with get_db() as db:
def update_function_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[FunctionModel]:
with get_db_context(db) as db:
try:
db.query(Function).filter_by(id=id).update(
{
@ -361,12 +381,12 @@ class FunctionsTable:
}
)
db.commit()
return self.get_function_by_id(id)
return self.get_function_by_id(id, db=db)
except Exception:
return None
def deactivate_all_functions(self) -> Optional[bool]:
with get_db() as db:
def deactivate_all_functions(self, db: Optional[Session] = None) -> Optional[bool]:
with get_db_context(db) as db:
try:
db.query(Function).update(
{
@ -379,8 +399,8 @@ class FunctionsTable:
except Exception:
return None
def delete_function_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_function_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(Function).filter_by(id=id).delete()
db.commit()

View file

@ -4,7 +4,8 @@ import time
from typing import Optional
import uuid
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.files import FileMetadataResponse
@ -120,9 +121,9 @@ class GroupListResponse(BaseModel):
class GroupTable:
def insert_new_group(
self, user_id: str, form_data: GroupForm
self, user_id: str, form_data: GroupForm, db: Optional[Session] = None
) -> Optional[GroupModel]:
with get_db() as db:
with get_db_context(db) as db:
group = GroupModel(
**{
**form_data.model_dump(exclude_none=True),
@ -146,54 +147,84 @@ class GroupTable:
except Exception:
return None
def get_all_groups(self) -> list[GroupModel]:
with get_db() as db:
def get_all_groups(self, db: Optional[Session] = None) -> list[GroupModel]:
with get_db_context(db) as db:
groups = db.query(Group).order_by(Group.updated_at.desc()).all()
return [GroupModel.model_validate(group) for group in groups]
def get_groups(self, filter) -> list[GroupResponse]:
with get_db() as db:
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
with get_db_context(db) as db:
query = db.query(Group)
if filter:
if "query" in filter:
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
if "member_id" in filter:
query = query.join(
GroupMember, GroupMember.group_id == Group.id
).filter(GroupMember.user_id == filter["member_id"])
# When share filter is present, member check is handled in the share logic
if "share" in filter:
share_value = filter["share"]
json_share = Group.data["config"]["share"].as_boolean()
member_id = filter.get("member_id")
json_share = Group.data["config"]["share"]
json_share_bool = json_share.as_boolean()
json_share_str = json_share.as_string()
if share_value:
query = query.filter(
or_(
Group.data.is_(None),
json_share.is_(None),
json_share == True,
)
# Groups open to anyone: data is null, share is null, or share is true
anyone_can_share = or_(
Group.data.is_(None),
json_share_bool.is_(None),
json_share_bool == True,
)
if member_id:
# Also include member-only groups where user is a member
member_groups_subq = (
db.query(GroupMember.group_id)
.filter(GroupMember.user_id == member_id)
.subquery()
)
members_only_and_is_member = and_(
json_share_str == "members",
Group.id.in_(member_groups_subq),
)
query = query.filter(
or_(anyone_can_share, members_only_and_is_member)
)
else:
query = query.filter(anyone_can_share)
else:
query = query.filter(
and_(Group.data.isnot(None), json_share == False)
and_(Group.data.isnot(None), json_share_bool == False)
)
else:
# Only apply member_id filter when share filter is NOT present
if "member_id" in filter:
query = query.join(
GroupMember, GroupMember.group_id == Group.id
).filter(GroupMember.user_id == filter["member_id"])
groups = query.order_by(Group.updated_at.desc()).all()
return [
GroupResponse.model_validate(
{
**GroupModel.model_validate(group).model_dump(),
"member_count": self.get_group_member_count_by_id(group.id),
"member_count": self.get_group_member_count_by_id(
group.id, db=db
),
}
)
for group in groups
]
def search_groups(
self, filter: Optional[dict] = None, skip: int = 0, limit: int = 30
self,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> GroupListResponse:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Group)
if filter:
@ -220,15 +251,17 @@ class GroupTable:
"items": [
GroupResponse.model_validate(
**GroupModel.model_validate(group).model_dump(),
member_count=self.get_group_member_count_by_id(group.id),
member_count=self.get_group_member_count_by_id(group.id, db=db),
)
for group in groups
],
"total": total,
}
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]:
with get_db() as db:
def get_groups_by_member_id(
self, user_id: str, db: Optional[Session] = None
) -> list[GroupModel]:
with get_db_context(db) as db:
return [
GroupModel.model_validate(group)
for group in db.query(Group)
@ -238,16 +271,41 @@ class GroupTable:
.all()
]
def get_group_by_id(self, id: str) -> Optional[GroupModel]:
def get_groups_by_member_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> dict[str, list[GroupModel]]:
"""Fetch groups for multiple users in a single query to avoid N+1."""
with get_db_context(db) as db:
# Query GroupMember joined with Group, filtering by user_ids
results = (
db.query(GroupMember.user_id, Group)
.join(Group, Group.id == GroupMember.group_id)
.filter(GroupMember.user_id.in_(user_ids))
.order_by(Group.updated_at.desc())
.all()
)
# Group groups by user_id
user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids}
for user_id, group in results:
user_groups[user_id].append(GroupModel.model_validate(group))
return user_groups
def get_group_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[GroupModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
return GroupModel.model_validate(group) if group else None
except Exception:
return None
def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]:
with get_db() as db:
def get_group_user_ids_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[list[str]]:
with get_db_context(db) as db:
members = (
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
)
@ -257,8 +315,10 @@ class GroupTable:
return [m[0] for m in members]
def get_group_user_ids_by_ids(self, group_ids: list[str]) -> dict[str, list[str]]:
with get_db() as db:
def get_group_user_ids_by_ids(
self, group_ids: list[str], db: Optional[Session] = None
) -> dict[str, list[str]]:
with get_db_context(db) as db:
members = (
db.query(GroupMember.group_id, GroupMember.user_id)
.filter(GroupMember.group_id.in_(group_ids))
@ -274,8 +334,10 @@ class GroupTable:
return group_user_ids
def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None:
with get_db() as db:
def set_group_user_ids_by_id(
self, group_id: str, user_ids: list[str], db: Optional[Session] = None
) -> None:
with get_db_context(db) as db:
# Delete existing members
db.query(GroupMember).filter(GroupMember.group_id == group_id).delete()
@ -295,8 +357,10 @@ class GroupTable:
db.add_all(new_members)
db.commit()
def get_group_member_count_by_id(self, id: str) -> int:
with get_db() as db:
def get_group_member_count_by_id(
self, id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db:
count = (
db.query(func.count(GroupMember.user_id))
.filter(GroupMember.group_id == id)
@ -305,10 +369,14 @@ class GroupTable:
return count if count else 0
def update_group_by_id(
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
self,
id: str,
form_data: GroupUpdateForm,
overwrite: bool = False,
db: Optional[Session] = None,
) -> Optional[GroupModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Group).filter_by(id=id).update(
{
**form_data.model_dump(exclude_none=True),
@ -316,22 +384,22 @@ class GroupTable:
}
)
db.commit()
return self.get_group_by_id(id=id)
return self.get_group_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def delete_group_by_id(self, id: str) -> bool:
def delete_group_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Group).filter_by(id=id).delete()
db.commit()
return True
except Exception:
return False
def delete_all_groups(self) -> bool:
with get_db() as db:
def delete_all_groups(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(Group).delete()
db.commit()
@ -340,8 +408,10 @@ class GroupTable:
except Exception:
return False
def remove_user_from_all_groups(self, user_id: str) -> bool:
with get_db() as db:
def remove_user_from_all_groups(
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try:
# Find all groups the user belongs to
groups = (
@ -369,16 +439,16 @@ class GroupTable:
return False
def create_groups_by_group_names(
self, user_id: str, group_names: list[str]
self, user_id: str, group_names: list[str], db: Optional[Session] = None
) -> list[GroupModel]:
# check for existing groups
existing_groups = self.get_all_groups()
existing_groups = self.get_all_groups(db=db)
existing_group_names = {group.name for group in existing_groups}
new_groups = []
with get_db() as db:
with get_db_context(db) as db:
for group_name in group_names:
if group_name not in existing_group_names:
new_group = GroupModel(
@ -400,8 +470,10 @@ class GroupTable:
continue
return new_groups
def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool:
with get_db() as db:
def sync_groups_by_group_names(
self, user_id: str, group_names: list[str], db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try:
now = int(time.time())
@ -461,10 +533,13 @@ class GroupTable:
return False
def add_users_to_group(
self, id: str, user_ids: Optional[list[str]] = None
self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[GroupModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
if not group:
return None
@ -499,10 +574,13 @@ class GroupTable:
return None
def remove_users_from_group(
self, id: str, user_ids: Optional[list[str]] = None
self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[GroupModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
if not group:
return None

View file

@ -4,7 +4,8 @@ import time
from typing import Optional
import uuid
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.files import (
File,
@ -157,9 +158,9 @@ class KnowledgeFileListResponse(BaseModel):
class KnowledgeTable:
def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm
self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
with get_db() as db:
with get_db_context(db) as db:
knowledge = KnowledgeModel(
**{
**form_data.model_dump(),
@ -183,15 +184,15 @@ class KnowledgeTable:
return None
def get_knowledge_bases(
self, skip: int = 0, limit: int = 30
self, skip: int = 0, limit: int = 30, db: Optional[Session] = None
) -> list[KnowledgeUserModel]:
with get_db() as db:
with get_db_context(db) as db:
all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
)
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
knowledge_bases = []
@ -208,10 +209,15 @@ class KnowledgeTable:
return knowledge_bases
def search_knowledge_bases(
self, user_id: str, filter: dict, skip: int = 0, limit: int = 30
self,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> KnowledgeListResponse:
try:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Knowledge, User).outerjoin(
User, User.id == Knowledge.user_id
)
@ -267,17 +273,17 @@ class KnowledgeTable:
return KnowledgeListResponse(items=[], total=0)
def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30
self, filter: dict, skip: int = 0, limit: int = 30, db: Optional[Session] = None
) -> KnowledgeFileListResponse:
"""
Scalable version: search files across all knowledge bases the user has
READ access to, without loading all KBs or using large IN() lists.
"""
try:
with get_db() as db:
with get_db_context(db) as db:
# Base query: join Knowledge → KnowledgeFile → File
query = (
db.query(File, User)
db.query(File, User, Knowledge)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
@ -307,7 +313,7 @@ class KnowledgeTable:
rows = query.all()
items = []
for file, user in rows:
for file, user, knowledge in rows:
items.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
@ -318,6 +324,9 @@ class KnowledgeTable:
if user
else None
),
collection=KnowledgeModel.model_validate(
knowledge
).model_dump(),
)
)
@ -327,20 +336,26 @@ class KnowledgeTable:
print("search_knowledge_files error:", e)
return KnowledgeFileListResponse(items=[], total=0)
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
knowledge = self.get_knowledge_by_id(id)
def check_access_by_user_id(
self, id, user_id, permission="write", db: Optional[Session] = None
) -> bool:
knowledge = self.get_knowledge_by_id(id, db=db)
if not knowledge:
return False
if knowledge.user_id == user_id:
return True
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write"
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[KnowledgeUserModel]:
knowledge_bases = self.get_knowledge_bases()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
knowledge_bases = self.get_knowledge_bases(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
knowledge_base
for knowledge_base in knowledge_bases
@ -350,32 +365,38 @@ class KnowledgeTable:
)
]
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
def get_knowledge_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
knowledge = db.query(Knowledge).filter_by(id=id).first()
return KnowledgeModel.model_validate(knowledge) if knowledge else None
except Exception:
return None
def get_knowledge_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
knowledge = self.get_knowledge_by_id(id)
knowledge = self.get_knowledge_by_id(id, db=db)
if not knowledge:
return None
if knowledge.user_id == user_id:
return knowledge
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
return knowledge
return None
def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]:
def get_knowledges_by_file_id(
self, file_id: str, db: Optional[Session] = None
) -> list[KnowledgeModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
knowledges = (
db.query(Knowledge)
.join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id)
@ -395,9 +416,10 @@ class KnowledgeTable:
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> KnowledgeFileListResponse:
try:
with get_db() as db:
with get_db_context(db) as db:
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
@ -470,9 +492,11 @@ class KnowledgeTable:
print(e)
return KnowledgeFileListResponse(items=[], total=0)
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
def get_files_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
files = (
db.query(File)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
@ -483,18 +507,24 @@ class KnowledgeTable:
except Exception:
return []
def get_file_metadatas_by_id(self, knowledge_id: str) -> list[FileMetadataResponse]:
def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileMetadataResponse]:
try:
with get_db() as db:
files = self.get_files_by_id(knowledge_id)
with get_db_context(db) as db:
files = self.get_files_by_id(knowledge_id, db=db)
return [FileMetadataResponse(**file.model_dump()) for file in files]
except Exception:
return []
def add_file_to_knowledge_by_id(
self, knowledge_id: str, file_id: str, user_id: str
self,
knowledge_id: str,
file_id: str,
user_id: str,
db: Optional[Session] = None,
) -> Optional[KnowledgeFileModel]:
with get_db() as db:
with get_db_context(db) as db:
knowledge_file = KnowledgeFileModel(
**{
"id": str(uuid.uuid4()),
@ -518,9 +548,11 @@ class KnowledgeTable:
except Exception:
return None
def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str) -> bool:
def remove_file_from_knowledge_by_id(
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(KnowledgeFile).filter_by(
knowledge_id=knowledge_id, file_id=file_id
).delete()
@ -529,9 +561,11 @@ class KnowledgeTable:
except Exception:
return False
def reset_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
def reset_knowledge_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
# Delete all knowledge_file entries for this knowledge_id
db.query(KnowledgeFile).filter_by(knowledge_id=id).delete()
db.commit()
@ -544,17 +578,21 @@ class KnowledgeTable:
)
db.commit()
return self.get_knowledge_by_id(id=id)
return self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def update_knowledge_by_id(
self, id: str, form_data: KnowledgeForm, overwrite: bool = False
self,
id: str,
form_data: KnowledgeForm,
overwrite: bool = False,
db: Optional[Session] = None,
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
knowledge = self.get_knowledge_by_id(id=id)
with get_db_context(db) as db:
knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update(
{
**form_data.model_dump(),
@ -562,17 +600,17 @@ class KnowledgeTable:
}
)
db.commit()
return self.get_knowledge_by_id(id=id)
return self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def update_knowledge_data_by_id(
self, id: str, data: dict
self, id: str, data: dict, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
knowledge = self.get_knowledge_by_id(id=id)
with get_db_context(db) as db:
knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update(
{
"data": data,
@ -580,22 +618,22 @@ class KnowledgeTable:
}
)
db.commit()
return self.get_knowledge_by_id(id=id)
return self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def delete_knowledge_by_id(self, id: str) -> bool:
def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Knowledge).filter_by(id=id).delete()
db.commit()
return True
except Exception:
return False
def delete_all_knowledge(self) -> bool:
with get_db() as db:
def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(Knowledge).delete()
db.commit()

View file

@ -2,7 +2,8 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
@ -41,8 +42,9 @@ class MemoriesTable:
self,
user_id: str,
content: str,
db: Optional[Session] = None,
) -> Optional[MemoryModel]:
with get_db() as db:
with get_db_context(db) as db:
id = str(uuid.uuid4())
memory = MemoryModel(
@ -68,8 +70,9 @@ class MemoriesTable:
id: str,
user_id: str,
content: str,
db: Optional[Session] = None,
) -> Optional[MemoryModel]:
with get_db() as db:
with get_db_context(db) as db:
try:
memory = db.get(Memory, id)
if not memory or memory.user_id != user_id:
@ -83,32 +86,36 @@ class MemoriesTable:
except Exception:
return None
def get_memories(self) -> list[MemoryModel]:
with get_db() as db:
def get_memories(self, db: Optional[Session] = None) -> list[MemoryModel]:
with get_db_context(db) as db:
try:
memories = db.query(Memory).all()
return [MemoryModel.model_validate(memory) for memory in memories]
except Exception:
return None
def get_memories_by_user_id(self, user_id: str) -> list[MemoryModel]:
with get_db() as db:
def get_memories_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[MemoryModel]:
with get_db_context(db) as db:
try:
memories = db.query(Memory).filter_by(user_id=user_id).all()
return [MemoryModel.model_validate(memory) for memory in memories]
except Exception:
return None
def get_memory_by_id(self, id: str) -> Optional[MemoryModel]:
with get_db() as db:
def get_memory_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[MemoryModel]:
with get_db_context(db) as db:
try:
memory = db.get(Memory, id)
return MemoryModel.model_validate(memory)
except Exception:
return None
def delete_memory_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_memory_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(Memory).filter_by(id=id).delete()
db.commit()
@ -118,8 +125,10 @@ class MemoriesTable:
except Exception:
return False
def delete_memories_by_user_id(self, user_id: str) -> bool:
with get_db() as db:
def delete_memories_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try:
db.query(Memory).filter_by(user_id=user_id).delete()
db.commit()
@ -128,8 +137,10 @@ class MemoriesTable:
except Exception:
return False
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
with get_db() as db:
def delete_memory_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try:
memory = db.get(Memory, id)
if not memory or memory.user_id != user_id:

View file

@ -3,7 +3,8 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.users import Users, User, UserNameResponse
from open_webui.models.channels import Channels, ChannelMember
@ -137,9 +138,13 @@ class MessageResponse(MessageReplyToResponse):
class MessageTable:
def insert_new_message(
self, form_data: MessageForm, channel_id: str, user_id: str
self,
form_data: MessageForm,
channel_id: str,
user_id: str,
db: Optional[Session] = None,
) -> Optional[MessageModel]:
with get_db() as db:
with get_db_context(db) as db:
channel_member = Channels.join_channel(channel_id, user_id)
id = str(uuid.uuid4())
@ -169,26 +174,57 @@ class MessageTable:
db.refresh(result)
return MessageModel.model_validate(result) if result else None
def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
with get_db() as db:
def get_message_by_id(
self,
id: str,
include_thread_replies: Optional[bool] = True,
db: Optional[Session] = None,
) -> Optional[MessageResponse]:
with get_db_context(db) as db:
message = db.get(Message, id)
if not message:
return None
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id
else None
)
reactions = self.get_reactions_by_message_id(id)
thread_replies = self.get_thread_replies_by_message_id(id)
reactions = self.get_reactions_by_message_id(id, db=db)
thread_replies = []
if include_thread_replies:
thread_replies = self.get_thread_replies_by_message_id(id, db=db)
# Check if message was sent by webhook (webhook info in meta takes precedence)
webhook_info = message.meta.get("webhook") if message.meta else None
if webhook_info and webhook_info.get("id"):
# Look up webhook by ID to get current name
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
# Webhook was deleted, use placeholder
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
else:
user = Users.get_user_by_id(message.user_id, db=db)
user_info = user.model_dump() if user else None
user = Users.get_user_by_id(message.user_id)
return MessageResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user.model_dump() if user else None,
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump() if reply_to_message else None
),
@ -200,8 +236,10 @@ class MessageTable:
}
)
def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
with get_db() as db:
def get_thread_replies_by_message_id(
self, id: str, db: Optional[Session] = None
) -> list[MessageReplyToResponse]:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
.filter_by(parent_id=id)
@ -212,14 +250,35 @@ class MessageTable:
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
@ -230,17 +289,23 @@ class MessageTable:
)
return messages
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
def get_reply_user_ids_by_message_id(
self, id: str, db: Optional[Session] = None
) -> list[str]:
with get_db_context(db) as db:
return [
message.user_id
for message in db.query(Message).filter_by(parent_id=id).all()
]
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageReplyToResponse]:
with get_db() as db:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id, parent_id=None)
@ -253,14 +318,35 @@ class MessageTable:
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
@ -272,9 +358,14 @@ class MessageTable:
return messages
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
self,
channel_id: str,
parent_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageReplyToResponse]:
with get_db() as db:
with get_db_context(db) as db:
message = db.get(Message, parent_id)
if not message:
@ -296,14 +387,35 @@ class MessageTable:
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
@ -314,8 +426,10 @@ class MessageTable:
)
return messages
def get_last_message_by_channel_id(self, channel_id: str) -> Optional[MessageModel]:
with get_db() as db:
def get_last_message_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> Optional[MessageModel]:
with get_db_context(db) as db:
message = (
db.query(Message)
.filter_by(channel_id=channel_id)
@ -325,9 +439,13 @@ class MessageTable:
return MessageModel.model_validate(message) if message else None
def get_pinned_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageModel]:
with get_db() as db:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id, is_pinned=True)
@ -339,9 +457,9 @@ class MessageTable:
return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id(
self, id: str, form_data: MessageForm
self, id: str, form_data: MessageForm, db: Optional[Session] = None
) -> Optional[MessageModel]:
with get_db() as db:
with get_db_context(db) as db:
message = db.get(Message, id)
message.content = form_data.content
message.data = {
@ -358,9 +476,13 @@ class MessageTable:
return MessageModel.model_validate(message) if message else None
def update_is_pinned_by_id(
self, id: str, is_pinned: bool, pinned_by: Optional[str] = None
self,
id: str,
is_pinned: bool,
pinned_by: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[MessageModel]:
with get_db() as db:
with get_db_context(db) as db:
message = db.get(Message, id)
message.is_pinned = is_pinned
message.pinned_at = int(time.time_ns()) if is_pinned else None
@ -370,9 +492,13 @@ class MessageTable:
return MessageModel.model_validate(message) if message else None
def get_unread_message_count(
self, channel_id: str, user_id: str, last_read_at: Optional[int] = None
self,
channel_id: str,
user_id: str,
last_read_at: Optional[int] = None,
db: Optional[Session] = None,
) -> int:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Message).filter(
Message.channel_id == channel_id,
Message.parent_id == None, # only count top-level messages
@ -383,9 +509,9 @@ class MessageTable:
return query.count()
def add_reaction_to_message(
self, id: str, user_id: str, name: str
self, id: str, user_id: str, name: str, db: Optional[Session] = None
) -> Optional[MessageReactionModel]:
with get_db() as db:
with get_db_context(db) as db:
# check for existing reaction
existing_reaction = (
db.query(MessageReaction)
@ -409,8 +535,10 @@ class MessageTable:
db.refresh(result)
return MessageReactionModel.model_validate(result) if result else None
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
with get_db() as db:
def get_reactions_by_message_id(
self, id: str, db: Optional[Session] = None
) -> list[Reactions]:
with get_db_context(db) as db:
# JOIN User so all user info is fetched in one query
results = (
db.query(MessageReaction, User)
@ -440,29 +568,29 @@ class MessageTable:
return [Reactions(**reaction) for reaction in reactions.values()]
def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str
self, id: str, user_id: str, name: str, db: Optional[Session] = None
) -> bool:
with get_db() as db:
with get_db_context(db) as db:
db.query(MessageReaction).filter_by(
message_id=id, user_id=user_id, name=name
).delete()
db.commit()
return True
def delete_reactions_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_reactions_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit()
return True
def delete_replies_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_replies_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
db.query(Message).filter_by(parent_id=id).delete()
db.commit()
return True
def delete_message_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_message_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
db.query(Message).filter_by(id=id).delete()
# Delete all reactions to this message
@ -471,5 +599,35 @@ class MessageTable:
db.commit()
return True
def search_messages_by_channel_ids(
self,
channel_ids: list[str],
query: str,
start_timestamp: Optional[int] = None,
end_timestamp: Optional[int] = None,
limit: int = 10,
db: Optional[Session] = None,
) -> list[MessageModel]:
"""Search messages in specified channels by content."""
with get_db_context(db) as db:
query_builder = db.query(Message).filter(
Message.channel_id.in_(channel_ids),
Message.content.ilike(f"%{query}%"),
)
if start_timestamp:
query_builder = query_builder.filter(
Message.created_at >= start_timestamp
)
if end_timestamp:
query_builder = query_builder.filter(
Message.created_at <= end_timestamp
)
messages = (
query_builder.order_by(Message.created_at.desc()).limit(limit).all()
)
return [MessageModel.model_validate(msg) for msg in messages]
Messages = MessageTable()

View file

@ -2,7 +2,8 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, Users, UserResponse
@ -129,6 +130,10 @@ class ModelUserResponse(ModelModel):
user: Optional[UserResponse] = None
class ModelAccessResponse(ModelUserResponse):
write_access: Optional[bool] = False
class ModelResponse(ModelModel):
pass
@ -138,6 +143,11 @@ class ModelListResponse(BaseModel):
total: int
class ModelAccessListResponse(BaseModel):
items: list[ModelAccessResponse]
total: int
class ModelForm(BaseModel):
id: str
base_model_id: Optional[str] = None
@ -150,7 +160,7 @@ class ModelForm(BaseModel):
class ModelsTable:
def insert_new_model(
self, form_data: ModelForm, user_id: str
self, form_data: ModelForm, user_id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
model = ModelModel(
**{
@ -161,7 +171,7 @@ class ModelsTable:
}
)
try:
with get_db() as db:
with get_db_context(db) as db:
result = Model(**model.model_dump())
db.add(result)
db.commit()
@ -175,17 +185,17 @@ class ModelsTable:
log.exception(f"Failed to insert a new model: {e}")
return None
def get_all_models(self) -> list[ModelModel]:
with get_db() as db:
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
return [ModelModel.model_validate(model) for model in db.query(Model).all()]
def get_models(self) -> list[ModelUserResponse]:
with get_db() as db:
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
with get_db_context(db) as db:
all_models = db.query(Model).filter(Model.base_model_id != None).all()
user_ids = list(set(model.user_id for model in all_models))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
models = []
@ -201,18 +211,20 @@ class ModelsTable:
)
return models
def get_base_models(self) -> list[ModelModel]:
with get_db() as db:
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
return [
ModelModel.model_validate(model)
for model in db.query(Model).filter(Model.base_model_id == None).all()
]
def get_models_by_user_id(
self, user_id: str, permission: str = "write"
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[ModelUserResponse]:
models = self.get_models()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
models = self.get_models(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
model
for model in models
@ -263,9 +275,14 @@ class ModelsTable:
return query
def search_models(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> ModelListResponse:
with get_db() as db:
with get_db_context(db) as db:
# Join GroupMember so we can order by group_id when requested
query = db.query(Model, User).outerjoin(User, User.id == Model.user_id)
query = query.filter(Model.base_model_id != None)
@ -291,7 +308,7 @@ class ModelsTable:
db,
query,
filter,
permission="write",
permission="read",
)
tag = filter.get("tag")
@ -349,24 +366,30 @@ class ModelsTable:
return ModelListResponse(items=models, total=total)
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
def get_model_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
model = db.get(Model, id)
return ModelModel.model_validate(model)
except Exception:
return None
def get_models_by_ids(self, ids: list[str]) -> list[ModelModel]:
def get_models_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[ModelModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
return [ModelModel.model_validate(model) for model in models]
except Exception:
return []
def toggle_model_by_id(self, id: str) -> Optional[ModelModel]:
with get_db() as db:
def toggle_model_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
with get_db_context(db) as db:
try:
is_active = db.query(Model).filter_by(id=id).first().is_active
@ -378,13 +401,15 @@ class ModelsTable:
)
db.commit()
return self.get_model_by_id(id)
return self.get_model_by_id(id, db=db)
except Exception:
return None
def update_model_by_id(self, id: str, model: ModelForm) -> Optional[ModelModel]:
def update_model_by_id(
self, id: str, model: ModelForm, db: Optional[Session] = None
) -> Optional[ModelModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
# update only the fields that are present in the model
data = model.model_dump(exclude={"id"})
result = db.query(Model).filter_by(id=id).update(data)
@ -398,9 +423,9 @@ class ModelsTable:
log.exception(f"Failed to update the model by id {id}: {e}")
return None
def delete_model_by_id(self, id: str) -> bool:
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Model).filter_by(id=id).delete()
db.commit()
@ -408,9 +433,9 @@ class ModelsTable:
except Exception:
return False
def delete_all_models(self) -> bool:
def delete_all_models(self, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Model).delete()
db.commit()
@ -418,9 +443,11 @@ class ModelsTable:
except Exception:
return False
def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]:
def sync_models(
self, user_id: str, models: list[ModelModel], db: Optional[Session] = None
) -> list[ModelModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
# Get existing models
existing_models = db.query(Model).all()
existing_ids = {model.id for model in existing_models}

View file

@ -4,7 +4,8 @@ import uuid
from typing import Optional
from functools import lru_cache
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from open_webui.models.users import User, UserModel, Users, UserResponse
@ -211,11 +212,9 @@ class NoteTable:
return query
def insert_new_note(
self,
form_data: NoteForm,
user_id: str,
self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
) -> Optional[NoteModel]:
with get_db() as db:
with get_db_context(db) as db:
note = NoteModel(
**{
"id": str(uuid.uuid4()),
@ -233,9 +232,9 @@ class NoteTable:
return note
def get_notes(
self, skip: Optional[int] = None, limit: Optional[int] = None
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
) -> list[NoteModel]:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Note).order_by(Note.updated_at.desc())
if skip is not None:
query = query.offset(skip)
@ -245,19 +244,32 @@ class NoteTable:
return [NoteModel.model_validate(note) for note in notes]
def search_notes(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> NoteListResponse:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
if filter:
query_key = filter.get("query")
if query_key:
# Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do")
normalized_query = query_key.replace("-", "").replace(" ", "")
query = query.filter(
or_(
Note.title.ilike(f"%{query_key}%"),
cast(Note.data["content"]["md"], Text).ilike(
f"%{query_key}%"
),
func.replace(
func.replace(Note.title, "-", ""), " ", ""
).ilike(f"%{normalized_query}%"),
func.replace(
func.replace(
cast(Note.data["content"]["md"], Text), "-", ""
),
" ",
"",
).ilike(f"%{normalized_query}%"),
)
)
@ -333,12 +345,13 @@ class NoteTable:
self,
user_id: str,
permission: str = "read",
skip: Optional[int] = None,
limit: Optional[int] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[NoteModel]:
with get_db() as db:
with get_db_context(db) as db:
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
]
query = db.query(Note).order_by(Note.updated_at.desc())
@ -354,15 +367,17 @@ class NoteTable:
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db:
def get_note_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[NoteModel]:
with get_db_context(db) as db:
note = db.query(Note).filter(Note.id == id).first()
return NoteModel.model_validate(note) if note else None
def update_note_by_id(
self, id: str, form_data: NoteUpdateForm
self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
) -> Optional[NoteModel]:
with get_db() as db:
with get_db_context(db) as db:
note = db.query(Note).filter(Note.id == id).first()
if not note:
return None
@ -384,11 +399,14 @@ class NoteTable:
db.commit()
return NoteModel.model_validate(note) if note else None
def delete_note_by_id(self, id: str):
with get_db() as db:
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True
def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True
except Exception:
return False
Notes = NoteTable()

View file

@ -8,7 +8,8 @@ import json
from cryptography.fernet import Fernet
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict
@ -109,10 +110,11 @@ class OAuthSessionTable:
user_id: str,
provider: str,
token: dict,
db: Optional[Session] = None,
) -> Optional[OAuthSessionModel]:
"""Create a new OAuth session"""
try:
with get_db() as db:
with get_db_context(db) as db:
current_time = int(time.time())
id = str(uuid.uuid4())
@ -141,10 +143,12 @@ class OAuthSessionTable:
log.error(f"Error creating OAuth session: {e}")
return None
def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
def get_session_by_id(
self, session_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID"""
try:
with get_db() as db:
with get_db_context(db) as db:
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
session.token = self._decrypt_token(session.token)
@ -156,11 +160,11 @@ class OAuthSessionTable:
return None
def get_session_by_id_and_user_id(
self, session_id: str, user_id: str
self, session_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID and user ID"""
try:
with get_db() as db:
with get_db_context(db) as db:
session = (
db.query(OAuthSession)
.filter_by(id=session_id, user_id=user_id)
@ -176,11 +180,11 @@ class OAuthSessionTable:
return None
def get_session_by_provider_and_user_id(
self, provider: str, user_id: str
self, provider: str, user_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by provider and user ID"""
try:
with get_db() as db:
with get_db_context(db) as db:
session = (
db.query(OAuthSession)
.filter_by(provider=provider, user_id=user_id)
@ -195,10 +199,12 @@ class OAuthSessionTable:
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]:
def get_sessions_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user"""
try:
with get_db() as db:
with get_db_context(db) as db:
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
results = []
@ -213,11 +219,11 @@ class OAuthSessionTable:
return []
def update_session_by_id(
self, session_id: str, token: dict
self, session_id: str, token: dict, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Update OAuth session tokens"""
try:
with get_db() as db:
with get_db_context(db) as db:
current_time = int(time.time())
db.query(OAuthSession).filter_by(id=session_id).update(
@ -239,10 +245,12 @@ class OAuthSessionTable:
log.error(f"Error updating OAuth session tokens: {e}")
return None
def delete_session_by_id(self, session_id: str) -> bool:
def delete_session_by_id(
self, session_id: str, db: Optional[Session] = None
) -> bool:
"""Delete an OAuth session"""
try:
with get_db() as db:
with get_db_context(db) as db:
result = db.query(OAuthSession).filter_by(id=session_id).delete()
db.commit()
return result > 0
@ -250,10 +258,12 @@ class OAuthSessionTable:
log.error(f"Error deleting OAuth session: {e}")
return False
def delete_sessions_by_user_id(self, user_id: str) -> bool:
def delete_sessions_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
"""Delete all OAuth sessions for a user"""
try:
with get_db() as db:
with get_db_context(db) as db:
result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
db.commit()
return True
@ -261,10 +271,12 @@ class OAuthSessionTable:
log.error(f"Error deleting OAuth sessions by user ID: {e}")
return False
def delete_sessions_by_provider(self, provider: str) -> bool:
def delete_sessions_by_provider(
self, provider: str, db: Optional[Session] = None
) -> bool:
"""Delete all OAuth sessions for a provider"""
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(OAuthSession).filter_by(provider=provider).delete()
db.commit()
return True

View file

@ -1,7 +1,8 @@
import time
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
@ -62,6 +63,10 @@ class PromptUserResponse(PromptModel):
user: Optional[UserResponse] = None
class PromptAccessResponse(PromptUserResponse):
write_access: Optional[bool] = False
class PromptForm(BaseModel):
command: str
title: str
@ -71,7 +76,7 @@ class PromptForm(BaseModel):
class PromptsTable:
def insert_new_prompt(
self, user_id: str, form_data: PromptForm
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]:
prompt = PromptModel(
**{
@ -82,7 +87,7 @@ class PromptsTable:
)
try:
with get_db() as db:
with get_db_context(db) as db:
result = Prompt(**prompt.model_dump())
db.add(result)
db.commit()
@ -94,21 +99,23 @@ class PromptsTable:
except Exception:
return None
def get_prompt_by_command(self, command: str) -> Optional[PromptModel]:
def get_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
return PromptModel.model_validate(prompt)
except Exception:
return None
def get_prompts(self) -> list[PromptUserResponse]:
with get_db() as db:
def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]:
with get_db_context(db) as db:
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
user_ids = list(set(prompt.user_id for prompt in all_prompts))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
prompts = []
@ -126,10 +133,12 @@ class PromptsTable:
return prompts
def get_prompts_by_user_id(
self, user_id: str, permission: str = "write"
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[PromptUserResponse]:
prompts = self.get_prompts()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
prompts = self.get_prompts(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
prompt
@ -139,10 +148,10 @@ class PromptsTable:
]
def update_prompt_by_command(
self, command: str, form_data: PromptForm
self, command: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
prompt.title = form_data.title
prompt.content = form_data.content
@ -153,9 +162,11 @@ class PromptsTable:
except Exception:
return None
def delete_prompt_by_command(self, command: str) -> bool:
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Prompt).filter_by(command=command).delete()
db.commit()

View file

@ -3,7 +3,8 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from pydantic import BaseModel, ConfigDict
@ -50,8 +51,10 @@ class TagChatIdForm(BaseModel):
class TagTable:
def insert_new_tag(self, name: str, user_id: str) -> Optional[TagModel]:
with get_db() as db:
def insert_new_tag(
self, name: str, user_id: str, db: Optional[Session] = None
) -> Optional[TagModel]:
with get_db_context(db) as db:
id = name.replace(" ", "_").lower()
tag = TagModel(**{"id": id, "user_id": user_id, "name": name})
try:
@ -68,27 +71,29 @@ class TagTable:
return None
def get_tag_by_name_and_user_id(
self, name: str, user_id: str
self, name: str, user_id: str, db: Optional[Session] = None
) -> Optional[TagModel]:
try:
id = name.replace(" ", "_").lower()
with get_db() as db:
with get_db_context(db) as db:
tag = db.query(Tag).filter_by(id=id, user_id=user_id).first()
return TagModel.model_validate(tag)
except Exception:
return None
def get_tags_by_user_id(self, user_id: str) -> list[TagModel]:
with get_db() as db:
def get_tags_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[TagModel]:
with get_db_context(db) as db:
return [
TagModel.model_validate(tag)
for tag in (db.query(Tag).filter_by(user_id=user_id).all())
]
def get_tags_by_ids_and_user_id(
self, ids: list[str], user_id: str
self, ids: list[str], user_id: str, db: Optional[Session] = None
) -> list[TagModel]:
with get_db() as db:
with get_db_context(db) as db:
return [
TagModel.model_validate(tag)
for tag in (
@ -96,9 +101,11 @@ class TagTable:
)
]
def delete_tag_by_name_and_user_id(self, name: str, user_id: str) -> bool:
def delete_tag_by_name_and_user_id(
self, name: str, user_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
id = name.replace(" ", "_").lower()
res = db.query(Tag).filter_by(id=id, user_id=user_id).delete()
log.debug(f"res: {res}")

View file

@ -2,7 +2,8 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
@ -96,6 +97,10 @@ class ToolUserResponse(ToolResponse):
model_config = ConfigDict(extra="allow")
class ToolAccessResponse(ToolUserResponse):
write_access: Optional[bool] = False
class ToolForm(BaseModel):
id: str
name: str
@ -110,9 +115,13 @@ class ToolValves(BaseModel):
class ToolsTable:
def insert_new_tool(
self, user_id: str, form_data: ToolForm, specs: list[dict]
self,
user_id: str,
form_data: ToolForm,
specs: list[dict],
db: Optional[Session] = None,
) -> Optional[ToolModel]:
with get_db() as db:
with get_db_context(db) as db:
tool = ToolModel(
**{
**form_data.model_dump(),
@ -136,21 +145,23 @@ class ToolsTable:
log.exception(f"Error creating a new tool: {e}")
return None
def get_tool_by_id(self, id: str) -> Optional[ToolModel]:
def get_tool_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ToolModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
tool = db.get(Tool, id)
return ToolModel.model_validate(tool)
except Exception:
return None
def get_tools(self) -> list[ToolUserModel]:
with get_db() as db:
def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]:
with get_db_context(db) as db:
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
user_ids = list(set(tool.user_id for tool in all_tools))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
tools = []
@ -167,10 +178,12 @@ class ToolsTable:
return tools
def get_tools_by_user_id(
self, user_id: str, permission: str = "write"
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[ToolUserModel]:
tools = self.get_tools()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
tools = self.get_tools(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
tool
@ -179,31 +192,35 @@ class ToolsTable:
or has_access(user_id, permission, tool.access_control, user_group_ids)
]
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
def get_tool_valves_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[dict]:
try:
with get_db() as db:
with get_db_context(db) as db:
tool = db.get(Tool, id)
return tool.valves if tool.valves else {}
except Exception as e:
log.exception(f"Error getting tool valves by id {id}")
return None
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
def update_tool_valves_by_id(
self, id: str, valves: dict, db: Optional[Session] = None
) -> Optional[ToolValves]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Tool).filter_by(id=id).update(
{"valves": valves, "updated_at": int(time.time())}
)
db.commit()
return self.get_tool_by_id(id)
return self.get_tool_by_id(id, db=db)
except Exception:
return None
def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "tools" and "valves" settings
@ -220,10 +237,10 @@ class ToolsTable:
return None
def update_user_valves_by_id_and_user_id(
self, id: str, user_id: str, valves: dict
self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "tools" and "valves" settings
@ -235,7 +252,7 @@ class ToolsTable:
user_settings["tools"]["valves"][id] = valves
# Update the user settings in the database
Users.update_user_by_id(user_id, {"settings": user_settings})
Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
return user_settings["tools"]["valves"][id]
except Exception as e:
@ -244,9 +261,11 @@ class ToolsTable:
)
return None
def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]:
def update_tool_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[ToolModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Tool).filter_by(id=id).update(
{**updated, "updated_at": int(time.time())}
)
@ -258,9 +277,9 @@ class ToolsTable:
except Exception:
return None
def delete_tool_by_id(self, id: str) -> bool:
def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Tool).filter_by(id=id).delete()
db.commit()

View file

@ -1,7 +1,8 @@
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
@ -243,8 +244,9 @@ class UsersTable:
profile_image_url: str = "/user.png",
role: str = "pending",
oauth: Optional[dict] = None,
db: Optional[Session] = None,
) -> Optional[UserModel]:
with get_db() as db:
with get_db_context(db) as db:
user = UserModel(
**{
"id": id,
@ -267,17 +269,21 @@ class UsersTable:
else:
return None
def get_user_by_id(self, id: str) -> Optional[UserModel]:
def get_user_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user)
except Exception:
return None
def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
def get_user_by_api_key(
self, api_key: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
user = (
db.query(User)
.join(ApiKey, User.id == ApiKey.user_id)
@ -288,17 +294,21 @@ class UsersTable:
except Exception:
return None
def get_user_by_email(self, email: str) -> Optional[UserModel]:
def get_user_by_email(
self, email: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).filter_by(email=email).first()
return UserModel.model_validate(user)
except Exception:
return None
def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]:
def get_user_by_oauth_sub(
self, provider: str, sub: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db: # type: Session
with get_db_context(db) as db: # type: Session
dialect_name = db.bind.dialect.name
query = db.query(User)
@ -320,8 +330,9 @@ class UsersTable:
filter: Optional[dict] = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
db: Optional[Session] = None,
) -> dict:
with get_db() as db:
with get_db_context(db) as db:
# Join GroupMember so we can order by group_id when requested
query = db.query(User)
@ -452,8 +463,10 @@ class UsersTable:
"total": total,
}
def get_users_by_group_id(self, group_id: str) -> list[UserModel]:
with get_db() as db:
def get_users_by_group_id(
self, group_id: str, db: Optional[Session] = None
) -> list[UserModel]:
with get_db_context(db) as db:
users = (
db.query(User)
.join(GroupMember, User.id == GroupMember.user_id)
@ -462,30 +475,34 @@ class UsersTable:
)
return [UserModel.model_validate(user) for user in users]
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserStatusModel]:
with get_db() as db:
def get_users_by_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> list[UserStatusModel]:
with get_db_context(db) as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
return [UserModel.model_validate(user) for user in users]
def get_num_users(self) -> Optional[int]:
with get_db() as db:
def get_num_users(self, db: Optional[Session] = None) -> Optional[int]:
with get_db_context(db) as db:
return db.query(User).count()
def has_users(self) -> bool:
with get_db() as db:
def has_users(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
return db.query(db.query(User).exists()).scalar()
def get_first_user(self) -> UserModel:
def get_first_user(self, db: Optional[Session] = None) -> UserModel:
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).order_by(User.created_at).first()
return UserModel.model_validate(user)
except Exception:
return None
def get_user_webhook_url_by_id(self, id: str) -> Optional[str]:
def get_user_webhook_url_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[str]:
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if user.settings is None:
@ -499,8 +516,8 @@ class UsersTable:
except Exception:
return None
def get_num_users_active_today(self) -> Optional[int]:
with get_db() as db:
def get_num_users_active_today(self, db: Optional[Session] = None) -> Optional[int]:
with get_db_context(db) as db:
current_timestamp = int(datetime.datetime.now().timestamp())
today_midnight_timestamp = current_timestamp - (current_timestamp % 86400)
query = db.query(User).filter(
@ -508,9 +525,11 @@ class UsersTable:
)
return query.count()
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
def update_user_role_by_id(
self, id: str, role: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update({"role": role})
db.commit()
user = db.query(User).filter_by(id=id).first()
@ -519,10 +538,10 @@ class UsersTable:
return None
def update_user_status_by_id(
self, id: str, form_data: UserStatus
self, id: str, form_data: UserStatus, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{**form_data.model_dump(exclude_none=True)}
)
@ -534,10 +553,10 @@ class UsersTable:
return None
def update_user_profile_image_url_by_id(
self, id: str, profile_image_url: str
self, id: str, profile_image_url: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{"profile_image_url": profile_image_url}
)
@ -549,9 +568,11 @@ class UsersTable:
return None
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
def update_last_active_by_id(self, id: str) -> Optional[UserModel]:
def update_last_active_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{"last_active_at": int(time.time())}
)
@ -563,7 +584,7 @@ class UsersTable:
return None
def update_user_oauth_by_id(
self, id: str, provider: str, sub: str
self, id: str, provider: str, sub: str, db: Optional[Session] = None
) -> Optional[UserModel]:
"""
Update or insert an OAuth provider/sub pair into the user's oauth JSON field.
@ -574,7 +595,7 @@ class UsersTable:
}
"""
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
@ -594,9 +615,11 @@ class UsersTable:
except Exception:
return None
def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
def update_user_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(updated)
db.commit()
@ -607,10 +630,16 @@ class UsersTable:
print(e)
return None
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
def update_user_settings_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
user_settings = db.query(User).filter_by(id=id).first().settings
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user_settings = user.settings
if user_settings is None:
user_settings = {}
@ -625,15 +654,15 @@ class UsersTable:
except Exception:
return None
def delete_user_by_id(self, id: str) -> bool:
def delete_user_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
# Remove User from Groups
Groups.remove_user_from_all_groups(id)
# Delete User Chats
result = Chats.delete_chats_by_user_id(id)
result = Chats.delete_chats_by_user_id(id, db=db)
if result:
with get_db() as db:
with get_db_context(db) as db:
# Delete User
db.query(User).filter_by(id=id).delete()
db.commit()
@ -644,17 +673,21 @@ class UsersTable:
except Exception:
return False
def get_user_api_key_by_id(self, id: str) -> Optional[str]:
def get_user_api_key_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[str]:
try:
with get_db() as db:
with get_db_context(db) as db:
api_key = db.query(ApiKey).filter_by(user_id=id).first()
return api_key.key if api_key else None
except Exception:
return None
def update_user_api_key_by_id(self, id: str, api_key: str) -> bool:
def update_user_api_key_by_id(
self, id: str, api_key: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(ApiKey).filter_by(user_id=id).delete()
db.commit()
@ -674,30 +707,32 @@ class UsersTable:
except Exception:
return False
def delete_user_api_key_by_id(self, id: str) -> bool:
def delete_user_api_key_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(ApiKey).filter_by(user_id=id).delete()
db.commit()
return True
except Exception:
return False
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
with get_db() as db:
def get_valid_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> list[str]:
with get_db_context(db) as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
return [user.id for user in users]
def get_super_admin_user(self) -> Optional[UserModel]:
with get_db() as db:
def get_super_admin_user(self, db: Optional[Session] = None) -> Optional[UserModel]:
with get_db_context(db) as db:
user = db.query(User).filter_by(role="admin").first()
if user:
return UserModel.model_validate(user)
else:
return None
def get_active_user_count(self) -> int:
with get_db() as db:
def get_active_user_count(self, db: Optional[Session] = None) -> int:
with get_db_context(db) as db:
# Consider user active if last_active_at within the last 3 minutes
three_minutes_ago = int(time.time()) - 180
count = (
@ -705,8 +740,8 @@ class UsersTable:
)
return count
def is_user_active(self, user_id: str) -> bool:
with get_db() as db:
def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=user_id).first()
if user and user.last_active_at:
# Consider user active if last_active_at within the last 3 minutes

View file

@ -30,7 +30,7 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.env import GLOBAL_LOG_LEVEL
from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -114,7 +114,7 @@ class TikaLoader:
endpoint += "/"
endpoint += "tika/text"
r = requests.put(endpoint, data=data, headers=headers)
r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY)
if r.ok:
raw_metadata = r.json()

View file

@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
from urllib.parse import quote
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, REQUESTS_VERIFY
from open_webui.retrieval.models.base_reranker import BaseReranker
from open_webui.utils.headers import include_user_info_headers
@ -55,6 +55,7 @@ class ExternalReranker(BaseReranker):
headers=headers,
json=payload,
timeout=self.timeout,
verify=REQUESTS_VERIFY,
)
r.raise_for_status()

View file

@ -40,8 +40,10 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS,
AIOHTTP_CLIENT_SESSION_SSL,
)
from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX,
@ -595,7 +597,9 @@ async def agenerate_openai_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f"{url}/embeddings", headers=headers, json=form_data
) as r:
@ -684,7 +688,9 @@ async def agenerate_azure_openai_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(full_url, headers=headers, json=form_data) as r:
r.raise_for_status()
data = await r.json()
@ -760,9 +766,14 @@ async def agenerate_ollama_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f"{url}/api/embed", headers=headers, json=form_data
f"{url}/api/embed",
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
@ -791,7 +802,9 @@ def get_embedding_function(
return await asyncio.to_thread(
(
lambda query, prefix=None: embedding_function.encode(
query, **({"prompt": prefix} if prefix else {})
query,
batch_size=int(embedding_batch_size),
**({"prompt": prefix} if prefix else {}),
).tolist()
),
query,

View file

@ -69,7 +69,11 @@ class ChromaClient(VectorDBBase):
return self.client.delete_collection(name=collection_name)
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
try:
@ -78,6 +82,7 @@ class ChromaClient(VectorDBBase):
result = collection.query(
query_embeddings=vectors,
n_results=limit,
where=filter,
)
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1

View file

@ -153,7 +153,11 @@ class ElasticsearchClient(VectorDBBase):
# Status: works
def search(
self, collection_name: str, vectors: list[list[float]], limit: int
self,
collection_name: str,
vectors: list[list[float]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
query = {
"size": limit,

View file

@ -179,7 +179,11 @@ class MilvusClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
collection_name = collection_name.replace("-", "_")

View file

@ -157,7 +157,11 @@ class MilvusClient(VectorDBBase):
collection.insert(entities)
def search(
self, collection_name: str, vectors: List[List[float]], limit: int
self,
collection_name: str,
vectors: List[List[float]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
if not vectors:
return None

View file

@ -0,0 +1,427 @@
from typing import Optional, List, Dict, Any
import logging
import re
import json
from sqlalchemy import (
func,
literal,
cast,
column,
create_engine,
Column,
Integer,
MetaData,
LargeBinary,
select,
text,
Text,
Table,
values,
)
from sqlalchemy.sql import true
from sqlalchemy.pool import NullPool, QueuePool
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
from sqlalchemy.dialects.postgresql import JSONB, array
from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2
from sqlalchemy.dialects import registry
class OpenGaussDialect(PGDialect_psycopg2):
name = "opengauss"
def _get_server_version_info(self, connection):
try:
version = connection.exec_driver_sql("SELECT version()").scalar()
if not version:
return (9, 0, 0)
match = re.search(
r"openGauss\s+(\d+)\.(\d+)\.(\d+)(?:-\w+)?", version, re.IGNORECASE
)
if match:
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
return super()._get_server_version_info(connection)
except Exception:
return (9, 0, 0)
# Register dialect
registry.register("opengauss", __name__, "OpenGaussDialect")
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
OPENGAUSS_DB_URL,
OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH,
OPENGAUSS_POOL_SIZE,
OPENGAUSS_POOL_MAX_OVERFLOW,
OPENGAUSS_POOL_TIMEOUT,
OPENGAUSS_POOL_RECYCLE,
)
from open_webui.env import SRC_LOG_LEVELS
VECTOR_LENGTH = OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class DocumentChunk(Base):
__tablename__ = "document_chunk"
id = Column(Text, primary_key=True)
vector = Column(Vector(dim=VECTOR_LENGTH), nullable=True)
collection_name = Column(Text, nullable=False)
text = Column(Text, nullable=True)
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
class OpenGaussClient(VectorDBBase):
def __init__(self) -> None:
if not OPENGAUSS_DB_URL:
from open_webui.internal.db import ScopedSession
self.session = ScopedSession
else:
engine_kwargs = {"pool_pre_ping": True, "dialect": OpenGaussDialect()}
if isinstance(OPENGAUSS_POOL_SIZE, int) and OPENGAUSS_POOL_SIZE > 0:
engine_kwargs.update(
{
"pool_size": OPENGAUSS_POOL_SIZE,
"max_overflow": OPENGAUSS_POOL_MAX_OVERFLOW,
"pool_timeout": OPENGAUSS_POOL_TIMEOUT,
"pool_recycle": OPENGAUSS_POOL_RECYCLE,
"poolclass": QueuePool,
}
)
else:
engine_kwargs["poolclass"] = NullPool
engine = create_engine(OPENGAUSS_DB_URL, **engine_kwargs)
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
)
self.session = scoped_session(SessionLocal)
try:
connection = self.session.connection()
Base.metadata.create_all(bind=connection)
self.session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_document_chunk_vector "
"ON document_chunk USING ivfflat (vector vector_cosine_ops) WITH (lists = 100);"
)
)
self.session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name "
"ON document_chunk (collection_name);"
)
)
self.session.commit()
log.info("OpenGauss vector database initialization completed.")
except Exception as e:
self.session.rollback()
log.exception(f"OpenGauss Initialization failed.: {e}")
raise
def check_vector_length(self) -> None:
metadata = MetaData()
try:
document_chunk_table = Table(
"document_chunk", metadata, autoload_with=self.session.bind
)
except NoSuchTableError:
return
if "vector" in document_chunk_table.columns:
vector_column = document_chunk_table.columns["vector"]
vector_type = vector_column.type
if isinstance(vector_type, Vector):
db_vector_length = vector_type.dim
if db_vector_length != VECTOR_LENGTH:
raise Exception(
f"Vector dimension mismatch: configured {VECTOR_LENGTH} vs. {db_vector_length} in the database."
)
else:
raise Exception("The 'vector' column type is not Vector.")
else:
raise Exception(
"The 'vector' column does not exist in the 'document_chunk' table."
)
def adjust_vector_length(self, vector: List[float]) -> List[float]:
current_length = len(vector)
if current_length < VECTOR_LENGTH:
vector += [0.0] * (VECTOR_LENGTH - current_length)
elif current_length > VECTOR_LENGTH:
vector = vector[:VECTOR_LENGTH]
return vector
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
try:
new_items = []
for item in items:
vector = self.adjust_vector_length(item["vector"])
new_chunk = DocumentChunk(
id=item["id"],
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=process_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
self.session.commit()
log.info(
f"Inserting {len(new_items)} items into collection '{collection_name}'."
)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to insert data: {e}")
raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
try:
for item in items:
vector = self.adjust_vector_length(item["vector"])
existing = (
self.session.query(DocumentChunk)
.filter(DocumentChunk.id == item["id"])
.first()
)
if existing:
existing.vector = vector
existing.text = item["text"]
existing.vmetadata = process_metadata(item["metadata"])
existing.collection_name = collection_name
else:
new_chunk = DocumentChunk(
id=item["id"],
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=process_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()
log.info(
f"Inserting/updating {len(items)} items in collection '{collection_name}'."
)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to insert or update data.: {e}")
raise
def search(
self,
collection_name: str,
vectors: List[List[float]],
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
) -> Optional[SearchResult]:
try:
if not vectors:
return None
vectors = [self.adjust_vector_length(vector) for vector in vectors]
num_queries = len(vectors)
def vector_expr(vector):
return cast(array(vector), Vector(VECTOR_LENGTH))
qid_col = column("qid", Integer)
q_vector_col = column("q_vector", Vector(VECTOR_LENGTH))
query_vectors = (
values(qid_col, q_vector_col)
.data(
[(idx, vector_expr(vector)) for idx, vector in enumerate(vectors)]
)
.alias("query_vectors")
)
result_fields = [
DocumentChunk.id,
DocumentChunk.text,
DocumentChunk.vmetadata,
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label(
"distance"
),
]
subq = (
select(*result_fields)
.where(DocumentChunk.collection_name == collection_name)
.order_by(
DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)
)
)
if limit is not None:
subq = subq.limit(limit)
subq = subq.lateral("result")
stmt = (
select(
query_vectors.c.qid,
subq.c.id,
subq.c.text,
subq.c.vmetadata,
subq.c.distance,
)
.select_from(query_vectors)
.join(subq, true())
.order_by(query_vectors.c.qid, subq.c.distance)
)
result_proxy = self.session.execute(stmt)
results = result_proxy.all()
ids = [[] for _ in range(num_queries)]
distances = [[] for _ in range(num_queries)]
documents = [[] for _ in range(num_queries)]
metadatas = [[] for _ in range(num_queries)]
for row in results:
qid = int(row.qid)
ids[qid].append(row.id)
distances[qid].append((2.0 - row.distance) / 2.0)
documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata)
self.session.rollback()
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
self.session.rollback()
log.exception(f"Vector search failed: {e}")
return None
def query(
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
) -> Optional[GetResult]:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
for key, value in filter.items():
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
if limit is not None:
query = query.limit(limit)
results = query.all()
if not results:
return None
ids = [[result.id for result in results]]
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback()
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
self.session.rollback()
log.exception(f"Conditional query failed: {e}")
return None
def get(
self, collection_name: str, limit: Optional[int] = None
) -> Optional[GetResult]:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
if limit is not None:
query = query.limit(limit)
results = query.all()
if not results:
return None
ids = [[result.id for result in results]]
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback()
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to retrieve data: {e}")
return None
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
) -> None:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
if ids:
query = query.filter(DocumentChunk.id.in_(ids))
if filter:
for key, value in filter.items():
query = query.filter(
DocumentChunk.vmetadata[key].astext == str(value)
)
deleted = query.delete(synchronize_session=False)
self.session.commit()
log.info(f"Deleted {deleted} items from collection '{collection_name}'")
except Exception as e:
self.session.rollback()
log.exception(f"Failed to delete data: {e}")
raise
def reset(self) -> None:
try:
deleted = self.session.query(DocumentChunk).delete()
self.session.commit()
log.info(f"Reset completed. Deleted {deleted} items")
except Exception as e:
self.session.rollback()
log.exception(f"Reset failed: {e}")
raise
def close(self) -> None:
pass
def has_collection(self, collection_name: str) -> bool:
try:
exists = (
self.session.query(DocumentChunk)
.filter(DocumentChunk.collection_name == collection_name)
.first()
is not None
)
self.session.rollback()
return exists
except Exception as e:
self.session.rollback()
log.exception(f"Failed to check collection existence: {e}")
return False
def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name)
log.info(f"Collection '{collection_name}' has been deleted")

View file

@ -113,7 +113,11 @@ class OpenSearchClient(VectorDBBase):
self.client.indices.delete(index=self._get_index_name(collection_name))
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
try:
if not self.has_collection(collection_name):

View file

@ -521,7 +521,11 @@ class Oracle23aiClient(VectorDBBase):
raise
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""
Search for similar vectors in the database.

View file

@ -90,9 +90,9 @@ class PgvectorClient(VectorDBBase):
# if no pgvector uri, use the existing database connection
if not PGVECTOR_DB_URL:
from open_webui.internal.db import Session
from open_webui.internal.db import ScopedSession
self.session = Session
self.session = ScopedSession
else:
if isinstance(PGVECTOR_POOL_SIZE, int):
if PGVECTOR_POOL_SIZE > 0:
@ -427,7 +427,8 @@ class PgvectorClient(VectorDBBase):
self,
collection_name: str,
vectors: List[List[float]],
limit: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
) -> Optional[SearchResult]:
try:
if not vectors:
@ -475,9 +476,47 @@ class PgvectorClient(VectorDBBase):
)
# Build the lateral subquery for each query vector
where_clauses = [DocumentChunk.collection_name == collection_name]
# Apply metadata filter if provided
if filter:
for key, value in filter.items():
if isinstance(value, dict) and "$in" in value:
# Handle $in operator: {"field": {"$in": [values]}}
in_values = value["$in"]
if PGVECTOR_PGCRYPTO:
where_clauses.append(
pgcrypto_decrypt(
DocumentChunk.vmetadata,
PGVECTOR_PGCRYPTO_KEY,
JSONB,
)[key].astext.in_([str(v) for v in in_values])
)
else:
where_clauses.append(
DocumentChunk.vmetadata[key].astext.in_(
[str(v) for v in in_values]
)
)
else:
# Handle simple equality: {"field": "value"}
if PGVECTOR_PGCRYPTO:
where_clauses.append(
pgcrypto_decrypt(
DocumentChunk.vmetadata,
PGVECTOR_PGCRYPTO_KEY,
JSONB,
)[key].astext
== str(value)
)
else:
where_clauses.append(
DocumentChunk.vmetadata[key].astext == str(value)
)
subq = (
select(*result_fields)
.where(DocumentChunk.collection_name == collection_name)
.where(*where_clauses)
.order_by(
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
)

View file

@ -391,7 +391,11 @@ class PineconeClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""Search for similar vectors in a collection."""
if not vectors or not vectors[0]:

View file

@ -145,7 +145,11 @@ class QdrantClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
if limit is None:

View file

@ -254,7 +254,11 @@ class QdrantClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: List[List[float | int]], limit: int
self,
collection_name: str,
vectors: List[List[float | int]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""
Search for the nearest neighbor items based on the vectors with tenant isolation.

View file

@ -295,7 +295,11 @@ class S3VectorClient(VectorDBBase):
raise
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""
Search for similar vectors in a collection using multiple query vectors.

View file

@ -159,7 +159,11 @@ class WeaviateClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):

View file

@ -53,6 +53,10 @@ class Vector:
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
return PgvectorClient()
case VectorType.OPENGAUSS:
from open_webui.retrieval.vector.dbs.opengauss import OpenGaussClient
return OpenGaussClient()
case VectorType.ELASTICSEARCH:
from open_webui.retrieval.vector.dbs.elasticsearch import (
ElasticsearchClient,

View file

@ -53,7 +53,11 @@ class VectorDBBase(ABC):
@abstractmethod
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""Search for similar vectors in a collection."""
pass

View file

@ -12,3 +12,4 @@ class VectorType(StrEnum):
ORACLE23AI = "oracle23ai"
S3VECTOR = "s3vector"
WEAVIATE = "weaviate"
OPENGAUSS = "opengauss"

View file

@ -1,4 +1,5 @@
import logging
import time
from typing import Optional
import requests
@ -25,6 +26,14 @@ def search_brave(
params = {"q": query, "count": count}
response = requests.get(url, headers=headers, params=params)
# Handle 429 rate limiting - Brave free tier allows 1 request/second
# If rate limited, wait 1 second and retry once before failing
if response.status_code == 429:
log.info("Brave Search API rate limited (429), retrying after 1 second...")
time.sleep(1)
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
json_response = response.json()

View file

@ -13,12 +13,14 @@ def search_duckduckgo(
count: int,
filter_list: Optional[list[str]] = None,
concurrent_requests: Optional[int] = None,
backend: Optional[str] = "auto",
) -> list[SearchResult]:
"""
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
Args:
query (str): The query to search for
count (int): The number of results to return
backend (str): The search backend to use (auto, duckduckgo, google, brave, etc.)
Returns:
list[SearchResult]: A list of search results
@ -32,7 +34,7 @@ def search_duckduckgo(
# Use the ddgs.text() method to perform the search
try:
search_results = ddgs.text(
query, safesearch="moderate", max_results=count, backend="lite"
query, safesearch="moderate", max_results=count, backend=backend
)
except RatelimitException as e:
log.error(f"RatelimitException: {e}")

View file

@ -7,17 +7,21 @@ from yarl import URL
log = logging.getLogger(__name__)
def search_jina(api_key: str, query: str, count: int) -> list[SearchResult]:
def search_jina(
api_key: str, query: str, count: int, base_url: str = ""
) -> list[SearchResult]:
"""
Search using Jina's Search API and return the results as a list of SearchResult objects.
Args:
api_key (str): The Jina API key
query (str): The query to search for
count (int): The number of results to return
base_url (str): Optional custom base URL for the Jina API
Returns:
list[SearchResult]: A list of search results
"""
jina_search_endpoint = "https://s.jina.ai/"
jina_search_endpoint = base_url if base_url else "https://s.jina.ai/"
headers = {
"Accept": "application/json",

View file

@ -36,6 +36,7 @@ from open_webui.config import (
WEB_LOADER_TIMEOUT,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
FIRECRAWL_TIMEOUT,
TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
EXTERNAL_WEB_LOADER_URL,
@ -189,6 +190,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
continue_on_failure: bool = True,
api_key: Optional[str] = None,
api_url: Optional[str] = None,
timeout: Optional[int] = None,
mode: Literal["crawl", "scrape", "map"] = "scrape",
proxy: Optional[Dict[str, str]] = None,
params: Optional[Dict] = None,
@ -231,6 +233,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
self.continue_on_failure = continue_on_failure
self.api_key = api_key
self.api_url = api_url
self.timeout = timeout
self.mode = mode
self.params = params or {}
@ -253,7 +256,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
ignore_invalid_urls=True,
remove_base64_images=True,
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
wait_timeout=len(self.web_paths) * 3,
wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3,
**self.params,
)
@ -294,7 +297,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
ignore_invalid_urls=True,
remove_base64_images=True,
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
wait_timeout=len(self.web_paths) * 3,
wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3,
**self.params,
)
@ -697,6 +700,11 @@ def get_web_loader(
WebLoaderClass = SafeFireCrawlLoader
web_loader_args["api_key"] = FIRECRAWL_API_KEY.value
web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value
if FIRECRAWL_TIMEOUT.value:
try:
web_loader_args["timeout"] = int(FIRECRAWL_TIMEOUT.value)
except ValueError:
pass
if WEB_LOADER_ENGINE.value == "tavily":
WebLoaderClass = SafeTavilyLoader

View file

@ -35,12 +35,16 @@ from pydantic import BaseModel
from open_webui.utils.misc import strict_match_mime_type
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.headers import include_user_info_headers
from open_webui.config import (
WHISPER_MODEL_AUTO_UPDATE,
WHISPER_COMPUTE_TYPE,
WHISPER_MODEL_DIR,
WHISPER_VAD_FILTER,
CACHE_DIR,
WHISPER_LANGUAGE,
WHISPER_MULTILINGUAL,
ELEVENLABS_API_BASE_URL,
)
@ -129,7 +133,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False):
faster_whisper_kwargs = {
"model_size_or_path": model,
"device": DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu",
"compute_type": "int8",
"compute_type": WHISPER_COMPUTE_TYPE,
"download_root": WHISPER_MODEL_DIR,
"local_files_only": not auto_update,
}
@ -328,6 +332,20 @@ def load_speech_pipeline(request):
@router.post("/speech")
async def speech(request: Request, user=Depends(get_verified_user)):
if request.app.state.config.TTS_ENGINE == "":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != "admin" and not has_permission(
user.id, "chat.tts", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
body = await request.body()
name = hashlib.sha256(
body
@ -585,8 +603,9 @@ def transcription_handler(request, file_path, metadata, user=None):
segments, info = model.transcribe(
file_path,
beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
vad_filter=WHISPER_VAD_FILTER,
language=languages[0],
multilingual=WHISPER_MULTILINGUAL,
)
log.info(
"Detected language '%s' with probability %f"
@ -1150,6 +1169,19 @@ def transcription(
language: Optional[str] = Form(None),
user=Depends(get_verified_user),
):
if request.app.state.config.STT_ENGINE == "":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != "admin" and not has_permission(
user.id, "chat.stt", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
log.info(f"file.content_type: {file.content_type}")
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []

View file

@ -62,6 +62,8 @@ from open_webui.utils.auth import (
get_password_hash,
get_http_authorization_cred,
)
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.webhook import post_webhook
from open_webui.utils.access_control import get_permissions, has_permission
from open_webui.utils.groups import apply_default_group_assignment
@ -103,7 +105,10 @@ class SessionUserInfoResponse(SessionUserResponse, UserStatus):
@router.get("/", response_model=SessionUserInfoResponse)
async def get_session_user(
request: Request, response: Response, user=Depends(get_current_user)
request: Request,
response: Response,
user=Depends(get_current_user),
db: Session = Depends(get_session),
):
auth_header = request.headers.get("Authorization")
@ -137,7 +142,7 @@ async def get_session_user(
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return {
@ -166,12 +171,15 @@ async def get_session_user(
@router.post("/update/profile", response_model=UserProfileImageResponse)
async def update_profile(
form_data: UpdateProfileForm, session_user=Depends(get_verified_user)
form_data: UpdateProfileForm,
session_user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if session_user:
user = Users.update_user_by_id(
session_user.id,
form_data.model_dump(),
db=db,
)
if user:
return user
@ -181,6 +189,32 @@ async def update_profile(
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
############################
# Update Timezone
############################
class UpdateTimezoneForm(BaseModel):
timezone: str
@router.post("/update/timezone")
async def update_timezone(
form_data: UpdateTimezoneForm,
session_user=Depends(get_current_user),
db: Session = Depends(get_session),
):
if session_user:
Users.update_user_by_id(
session_user.id,
{"timezone": form_data.timezone},
db=db,
)
return {"status": True}
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
############################
# Update Password
############################
@ -188,13 +222,17 @@ async def update_profile(
@router.post("/update/password", response_model=bool)
async def update_password(
form_data: UpdatePasswordForm, session_user=Depends(get_current_user)
form_data: UpdatePasswordForm,
session_user=Depends(get_current_user),
db: Session = Depends(get_session),
):
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
if session_user:
user = Auths.authenticate_user(
session_user.email, lambda pw: verify_password(form_data.password, pw)
session_user.email,
lambda pw: verify_password(form_data.password, pw),
db=db,
)
if user:
@ -203,7 +241,7 @@ async def update_password(
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.new_password)
return Auths.update_user_password_by_id(user.id, hashed)
return Auths.update_user_password_by_id(user.id, hashed, db=db)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD)
else:
@ -214,7 +252,12 @@ async def update_password(
# LDAP Authentication
############################
@router.post("/ldap", response_model=SessionUserResponse)
async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
async def ldap_auth(
request: Request,
response: Response,
form_data: LdapForm,
db: Session = Depends(get_session),
):
# Security checks FIRST - before loading any config
if not request.app.state.config.ENABLE_LDAP:
raise HTTPException(400, detail="LDAP authentication is not enabled")
@ -400,12 +443,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
if not connection_user.bind():
raise HTTPException(400, "Authentication failed.")
user = Users.get_user_by_email(email)
user = Users.get_user_by_email(email, db=db)
if not user:
try:
role = (
"admin"
if not Users.has_users()
if not Users.has_users(db=db)
else request.app.state.config.DEFAULT_USER_ROLE
)
@ -414,6 +457,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
password=str(uuid.uuid4()),
name=cn,
role=role,
db=db,
)
if not user:
@ -424,6 +468,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
except HTTPException:
@ -434,7 +479,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
500, detail="Internal error occurred during LDAP user creation."
)
user = Auths.authenticate_user_by_email(email)
user = Auths.authenticate_user_by_email(email, db=db)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
@ -464,7 +509,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
if (
@ -473,9 +518,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
and user_groups
):
if ENABLE_LDAP_GROUP_CREATION:
Groups.create_groups_by_group_names(user.id, user_groups)
Groups.create_groups_by_group_names(user.id, user_groups, db=db)
try:
Groups.sync_groups_by_group_names(user.id, user_groups)
Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
log.info(
f"Successfully synced groups for user {user.id}: {user_groups}"
)
@ -508,7 +553,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
@router.post("/signin", response_model=SessionUserResponse)
async def signin(request: Request, response: Response, form_data: SigninForm):
async def signin(
request: Request,
response: Response,
form_data: SigninForm,
db: Session = Depends(get_session),
):
if not ENABLE_PASSWORD_AUTH:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -529,14 +579,15 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
except Exception as e:
pass
if not Users.get_user_by_email(email.lower()):
if not Users.get_user_by_email(email.lower(), db=db):
await signup(
request,
response,
SignupForm(email=email, password=str(uuid.uuid4()), name=name),
db=db,
)
user = Auths.authenticate_user_by_email(email)
user = Auths.authenticate_user_by_email(email, db=db)
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin":
group_names = request.headers.get(
WEBUI_AUTH_TRUSTED_GROUPS_HEADER, ""
@ -544,28 +595,33 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
group_names = [name.strip() for name in group_names if name.strip()]
if group_names:
Groups.sync_groups_by_group_names(user.id, group_names)
Groups.sync_groups_by_group_names(user.id, group_names, db=db)
elif WEBUI_AUTH == False:
admin_email = "admin@localhost"
admin_password = "admin"
if Users.get_user_by_email(admin_email.lower()):
if Users.get_user_by_email(admin_email.lower(), db=db):
user = Auths.authenticate_user(
admin_email.lower(), lambda pw: verify_password(admin_password, pw)
admin_email.lower(),
lambda pw: verify_password(admin_password, pw),
db=db,
)
else:
if Users.has_users():
if Users.has_users(db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
await signup(
request,
response,
SignupForm(email=admin_email, password=admin_password, name="User"),
db=db,
)
user = Auths.authenticate_user(
admin_email.lower(), lambda pw: verify_password(admin_password, pw)
admin_email.lower(),
lambda pw: verify_password(admin_password, pw),
db=db,
)
else:
if signin_rate_limiter.is_limited(form_data.email.lower()):
@ -584,7 +640,9 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
form_data.password = password_bytes.decode("utf-8", errors="ignore")
user = Auths.authenticate_user(
form_data.email.lower(), lambda pw: verify_password(form_data.password, pw)
form_data.email.lower(),
lambda pw: verify_password(form_data.password, pw),
db=db,
)
if user:
@ -616,7 +674,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return {
@ -640,8 +698,13 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
@router.post("/signup", response_model=SessionUserResponse)
async def signup(request: Request, response: Response, form_data: SignupForm):
has_users = Users.has_users()
async def signup(
request: Request,
response: Response,
form_data: SignupForm,
db: Session = Depends(get_session),
):
has_users = Users.has_users(db=db)
if WEBUI_AUTH:
if (
@ -663,7 +726,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
if Users.get_user_by_email(form_data.email.lower()):
if Users.get_user_by_email(form_data.email.lower(), db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
@ -681,6 +744,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
form_data.name,
form_data.profile_image_url,
role,
db=db,
)
if user:
@ -723,7 +787,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
if not has_users:
@ -733,6 +797,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
return {
@ -754,7 +819,9 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
@router.get("/signout")
async def signout(request: Request, response: Response):
async def signout(
request: Request, response: Response, db: Session = Depends(get_session)
):
# get auth token from headers or cookies
token = None
@ -776,7 +843,7 @@ async def signout(request: Request, response: Response):
if oauth_session_id:
response.delete_cookie("oauth_session_id")
session = OAuthSessions.get_session_by_id(oauth_session_id)
session = OAuthSessions.get_session_by_id(oauth_session_id, db=db)
oauth_server_metadata_url = (
request.app.state.oauth_manager.get_server_metadata_url(session.provider)
if session
@ -839,14 +906,17 @@ async def signout(request: Request, response: Response):
@router.post("/add", response_model=SigninResponse)
async def add_user(
request: Request, form_data: AddUserForm, user=Depends(get_admin_user)
request: Request,
form_data: AddUserForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
if not validate_email_format(form_data.email.lower()):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
if Users.get_user_by_email(form_data.email.lower()):
if Users.get_user_by_email(form_data.email.lower(), db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
@ -862,12 +932,14 @@ async def add_user(
form_data.name,
form_data.profile_image_url,
form_data.role,
db=db,
)
if user:
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
token = create_token(data={"id": user.id})
@ -895,7 +967,9 @@ async def add_user(
@router.get("/admin/details")
async def get_admin_details(request: Request, user=Depends(get_current_user)):
async def get_admin_details(
request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)
):
if request.app.state.config.SHOW_ADMIN_DETAILS:
admin_email = request.app.state.config.ADMIN_EMAIL
admin_name = None
@ -903,11 +977,11 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}")
if admin_email:
admin = Users.get_user_by_email(admin_email)
admin = Users.get_user_by_email(admin_email, db=db)
if admin:
admin_name = admin.name
else:
admin = Users.get_first_user()
admin = Users.get_first_user(db=db)
if admin:
admin_email = admin.email
admin_name = admin.name
@ -929,6 +1003,7 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
async def get_admin_config(request: Request, user=Depends(get_admin_user)):
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
@ -940,9 +1015,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
"FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
"ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS,
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
@ -951,6 +1029,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
class AdminConfig(BaseModel):
SHOW_ADMIN_DETAILS: bool
ADMIN_EMAIL: Optional[str] = None
WEBUI_URL: str
ENABLE_SIGNUP: bool
ENABLE_API_KEYS: bool
@ -962,9 +1041,12 @@ class AdminConfig(BaseModel):
ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool
ENABLE_FOLDERS: bool
FOLDER_MAX_FILE_COUNT: Optional[int | str] = None
ENABLE_CHANNELS: bool
ENABLE_MEMORIES: bool
ENABLE_NOTES: bool
ENABLE_USER_WEBHOOKS: bool
ENABLE_USER_STATUS: bool
PENDING_USER_OVERLAY_TITLE: Optional[str] = None
PENDING_USER_OVERLAY_CONTENT: Optional[str] = None
RESPONSE_WATERMARK: Optional[str] = None
@ -975,6 +1057,7 @@ async def update_admin_config(
request: Request, form_data: AdminConfig, user=Depends(get_admin_user)
):
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
@ -987,7 +1070,11 @@ async def update_admin_config(
)
request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS
request.app.state.config.FOLDER_MAX_FILE_COUNT = (
int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ""
)
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
@ -1007,6 +1094,7 @@ async def update_admin_config(
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS
request.app.state.config.PENDING_USER_OVERLAY_TITLE = (
form_data.PENDING_USER_OVERLAY_TITLE
@ -1019,6 +1107,7 @@ async def update_admin_config(
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
@ -1030,9 +1119,12 @@ async def update_admin_config(
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
"FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
"ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS,
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
@ -1149,7 +1241,9 @@ async def update_ldap_config(
# create api key
@router.post("/api_key", response_model=ApiKey)
async def generate_api_key(request: Request, user=Depends(get_current_user)):
async def generate_api_key(
request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)
):
if not request.app.state.config.ENABLE_API_KEYS or not has_permission(
user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS
):
@ -1159,7 +1253,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)):
)
api_key = create_api_key()
success = Users.update_user_api_key_by_id(user.id, api_key)
success = Users.update_user_api_key_by_id(user.id, api_key, db=db)
if success:
return {
@ -1171,14 +1265,18 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)):
# delete api key
@router.delete("/api_key", response_model=bool)
async def delete_api_key(user=Depends(get_current_user)):
return Users.delete_user_api_key_by_id(user.id)
async def delete_api_key(
user=Depends(get_current_user), db: Session = Depends(get_session)
):
return Users.delete_user_api_key_by_id(user.id, db=db)
# get api key
@router.get("/api_key", response_model=ApiKey)
async def get_api_key(user=Depends(get_current_user)):
api_key = Users.get_user_api_key_by_id(user.id)
async def get_api_key(
user=Depends(get_current_user), db: Session = Depends(get_session)
):
api_key = Users.get_user_api_key_by_id(user.id, db=db)
if api_key:
return {
"api_key": api_key,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import aiohttp
from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import get_config, save_config
from open_webui.config import BannerModel
@ -228,7 +229,10 @@ async def verify_tool_servers_config(
log.debug(
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
)
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
async with session.get(
discovery_url
) as oauth_server_metadata_response:

View file

@ -1,5 +1,7 @@
from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from open_webui.models.users import Users, UserModel
@ -10,15 +12,268 @@ from open_webui.models.feedbacks import (
FeedbackForm,
FeedbackUserResponse,
FeedbackListResponse,
LeaderboardFeedbackData,
ModelHistoryEntry,
ModelHistoryResponse,
Feedbacks,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
router = APIRouter()
# Leaderboard Elo Rating Computation
#
# How it works:
# 1. Each model starts with a rating of 1000
# 2. When a user picks a winner between two models, ratings are adjusted:
# - Winner gains points, loser loses points
# - The amount depends on expected outcome (upset = bigger change)
# 3. The Elo formula: new_rating = old_rating + K * (actual - expected)
# - K=32 controls how much ratings can change per match
# - expected = probability of winning based on current ratings
#
# Query-based re-ranking (optional):
# When a user searches for a topic (e.g., "coding"), we want to show
# which models perform best FOR THAT TOPIC. We do this by:
# 1. Computing semantic similarity between the query and each feedback's tags
# 2. Using that similarity as a weight in the Elo calculation
# 3. Feedbacks about "coding" contribute more to the final ranking
# 4. Feedbacks about unrelated topics (e.g., "cooking") contribute less
# This gives topic-specific leaderboards without needing separate data.
import os
EMBEDDING_MODEL_NAME = os.environ.get(
"AUXILIARY_EMBEDDING_MODEL", "TaylorAI/bge-micro-v2"
)
_embedding_model = None
def _get_embedding_model():
global _embedding_model
if _embedding_model is None:
try:
from sentence_transformers import SentenceTransformer
_embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
except Exception as e:
log.error(f"Embedding model load failed: {e}")
return _embedding_model
def _calculate_elo(
feedbacks: list[LeaderboardFeedbackData], similarities: dict = None
) -> dict:
"""
Calculate Elo ratings for models based on user feedback.
Each feedback represents a comparison where a user rated one model
against its opponents (sibling_model_ids). Rating=1 means the model won,
rating=-1 means it lost.
The Elo system adjusts ratings based on:
- Current rating difference (upsets cause bigger swings)
- Optional similarity weights (for query-based filtering)
Returns: {model_id: {"rating": float, "won": int, "lost": int}}
"""
K_FACTOR = 32 # Standard Elo K-factor for rating volatility
model_stats = {}
def get_or_create_stats(model_id):
if model_id not in model_stats:
model_stats[model_id] = {"rating": 1000.0, "won": 0, "lost": 0}
return model_stats[model_id]
for feedback in feedbacks:
data = feedback.data or {}
winner_id = data.get("model_id")
rating_value = str(data.get("rating", ""))
if not winner_id or rating_value not in ("1", "-1"):
continue
won = rating_value == "1"
weight = similarities.get(feedback.id, 1.0) if similarities else 1.0
for opponent_id in data.get("sibling_model_ids") or []:
winner = get_or_create_stats(winner_id)
opponent = get_or_create_stats(opponent_id)
expected = 1 / (1 + 10 ** ((opponent["rating"] - winner["rating"]) / 400))
winner["rating"] += K_FACTOR * ((1 if won else 0) - expected) * weight
opponent["rating"] += (
K_FACTOR * ((0 if won else 1) - (1 - expected)) * weight
)
if won:
winner["won"] += 1
opponent["lost"] += 1
else:
winner["lost"] += 1
opponent["won"] += 1
return model_stats
def _get_top_tags(feedbacks: list[LeaderboardFeedbackData], limit: int = 5) -> dict:
"""
Count tag occurrences per model and return the most frequent ones.
Each feedback can have tags describing the conversation topic.
This aggregates those tags per model to show what topics each model
is commonly used for.
Returns: {model_id: [{"tag": str, "count": int}, ...]}
"""
from collections import defaultdict
tag_counts = defaultdict(lambda: defaultdict(int))
for feedback in feedbacks:
data = feedback.data or {}
model_id = data.get("model_id")
if model_id:
for tag in data.get("tags", []):
tag_counts[model_id][tag] += 1
return {
model_id: [
{"tag": tag, "count": count}
for tag, count in sorted(tags.items(), key=lambda x: -x[1])[:limit]
]
for model_id, tags in tag_counts.items()
}
def _compute_similarities(feedbacks: list[LeaderboardFeedbackData], query: str) -> dict:
"""
Compute how relevant each feedback is to a search query.
Uses embeddings to find semantic similarity between the query and
each feedback's tags. Higher similarity means the feedback is more
relevant to what the user searched for.
This is used to weight Elo calculations - feedbacks matching the
query have more influence on the final rankings.
Returns: {feedback_id: similarity_score (0-1)}
"""
import numpy as np
embedding_model = _get_embedding_model()
if not embedding_model:
return {}
all_tags = list(
{
tag
for feedback in feedbacks
if feedback.data
for tag in feedback.data.get("tags", [])
}
)
if not all_tags:
return {}
try:
tag_embeddings = embedding_model.encode(all_tags)
query_embedding = embedding_model.encode([query])[0]
except Exception as e:
log.error(f"Embedding error: {e}")
return {}
# Vectorized cosine similarity
tag_norms = np.linalg.norm(tag_embeddings, axis=1)
query_norm = np.linalg.norm(query_embedding)
similarities = np.dot(tag_embeddings, query_embedding) / (
tag_norms * query_norm + 1e-9
)
tag_similarity_map = dict(zip(all_tags, similarities.tolist()))
return {
feedback.id: max(
(
tag_similarity_map.get(tag, 0)
for tag in (feedback.data or {}).get("tags", [])
),
default=0,
)
for feedback in feedbacks
}
class LeaderboardEntry(BaseModel):
model_id: str
rating: int
won: int
lost: int
count: int
top_tags: list[dict]
class LeaderboardResponse(BaseModel):
entries: list[LeaderboardEntry]
@router.get("/leaderboard", response_model=LeaderboardResponse)
async def get_leaderboard(
query: Optional[str] = None,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get model leaderboard with Elo ratings. Query filters by tag similarity."""
feedbacks = Feedbacks.get_feedbacks_for_leaderboard(db=db)
similarities = None
if query and query.strip():
similarities = await run_in_threadpool(
_compute_similarities, feedbacks, query.strip()
)
elo_stats = _calculate_elo(feedbacks, similarities)
tags_by_model = _get_top_tags(feedbacks)
entries = sorted(
[
LeaderboardEntry(
model_id=mid,
rating=round(s["rating"]),
won=s["won"],
lost=s["lost"],
count=s["won"] + s["lost"],
top_tags=tags_by_model.get(mid, []),
)
for mid, s in elo_stats.items()
],
key=lambda e: e.rating,
reverse=True,
)
return LeaderboardResponse(entries=entries)
@router.get("/leaderboard/{model_id}/history", response_model=ModelHistoryResponse)
async def get_model_history(
model_id: str,
days: int = 30,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get daily win/loss history for a specific model."""
history = Feedbacks.get_model_evaluation_history(
model_id=model_id, days=days, db=db
)
return ModelHistoryResponse(model_id=model_id, history=history)
############################
# GetConfig
############################
@ -60,38 +315,49 @@ async def update_config(
@router.get("/feedbacks/all", response_model=list[FeedbackResponse])
async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
async def get_all_feedbacks(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
return feedbacks
@router.get("/feedbacks/all/ids", response_model=list[FeedbackIdResponse])
async def get_all_feedback_ids(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
return feedbacks
async def get_all_feedback_ids(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Feedbacks.get_all_feedback_ids(db=db)
@router.delete("/feedbacks/all")
async def delete_all_feedbacks(user=Depends(get_admin_user)):
success = Feedbacks.delete_all_feedbacks()
async def delete_all_feedbacks(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
success = Feedbacks.delete_all_feedbacks(db=db)
return success
@router.get("/feedbacks/all/export", response_model=list[FeedbackModel])
async def export_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
async def export_all_feedbacks(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
return feedbacks
@router.get("/feedbacks/user", response_model=list[FeedbackUserResponse])
async def get_feedbacks(user=Depends(get_verified_user)):
feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id)
async def get_feedbacks(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id, db=db)
return feedbacks
@router.delete("/feedbacks", response_model=bool)
async def delete_feedbacks(user=Depends(get_verified_user)):
success = Feedbacks.delete_feedbacks_by_user_id(user.id)
async def delete_feedbacks(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
success = Feedbacks.delete_feedbacks_by_user_id(user.id, db=db)
return success
@ -104,6 +370,7 @@ async def get_feedbacks(
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
@ -116,7 +383,7 @@ async def get_feedbacks(
if direction:
filter["direction"] = direction
result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit)
result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db)
return result
@ -125,8 +392,11 @@ async def create_feedback(
request: Request,
form_data: FeedbackForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
feedback = Feedbacks.insert_new_feedback(user_id=user.id, form_data=form_data)
feedback = Feedbacks.insert_new_feedback(
user_id=user.id, form_data=form_data, db=db
)
if not feedback:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -137,11 +407,15 @@ async def create_feedback(
@router.get("/feedback/{id}", response_model=FeedbackModel)
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
async def get_feedback_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin":
feedback = Feedbacks.get_feedback_by_id(id=id)
feedback = Feedbacks.get_feedback_by_id(id=id, db=db)
else:
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
feedback = Feedbacks.get_feedback_by_id_and_user_id(
id=id, user_id=user.id, db=db
)
if not feedback:
raise HTTPException(
@ -153,13 +427,16 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/feedback/{id}", response_model=FeedbackModel)
async def update_feedback_by_id(
id: str, form_data: FeedbackForm, user=Depends(get_verified_user)
id: str,
form_data: FeedbackForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role == "admin":
feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data)
feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data, db=db)
else:
feedback = Feedbacks.update_feedback_by_id_and_user_id(
id=id, user_id=user.id, form_data=form_data
id=id, user_id=user.id, form_data=form_data, db=db
)
if not feedback:
@ -171,11 +448,15 @@ async def update_feedback_by_id(
@router.delete("/feedback/{id}")
async def delete_feedback_by_id(id: str, user=Depends(get_verified_user)):
async def delete_feedback_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin":
success = Feedbacks.delete_feedback_by_id(id=id)
success = Feedbacks.delete_feedback_by_id(id=id, db=db)
else:
success = Feedbacks.delete_feedback_by_id_and_user_id(id=id, user_id=user.id)
success = Feedbacks.delete_feedback_by_id_and_user_id(
id=id, user_id=user.id, db=db
)
if not success:
raise HTTPException(

View file

@ -2,7 +2,6 @@ import logging
import os
import uuid
import json
from fnmatch import fnmatch
from pathlib import Path
from typing import Optional
from urllib.parse import quote
@ -22,6 +21,8 @@ from fastapi import (
)
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session, SessionLocal
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
@ -62,9 +63,12 @@ router = APIRouter()
# TODO: Optimize this function to use the knowledge_file table for faster lookups.
def has_access_to_file(
file_id: Optional[str], access_type: str, user=Depends(get_verified_user)
file_id: Optional[str],
access_type: str,
user=Depends(get_verified_user),
db: Optional[Session] = None,
) -> bool:
file = Files.get_file_by_id(file_id)
file = Files.get_file_by_id(file_id, db=db)
log.debug(f"Checking if user has {access_type} access to file")
if not file:
raise HTTPException(
@ -73,31 +77,33 @@ def has_access_to_file(
)
# Check if the file is associated with any knowledge bases the user has access to
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or has_access(
user.id, access_type, knowledge_base.access_control, user_group_ids
user.id, access_type, knowledge_base.access_control, user_group_ids, db=db
):
return True
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type
user.id, access_type, db=db
)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id:
return True
# Check if the file is associated with any channels the user has access to
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id)
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
if access_type == "read" and channels:
return True
# Check if the file is associated with any chats the user has access to
# TODO: Granular access control for chats
chats = Chats.get_shared_chats_by_file_id(file_id)
chats = Chats.get_shared_chats_by_file_id(file_id, db=db)
if chats:
return True
@ -109,47 +115,78 @@ def has_access_to_file(
############################
def process_uploaded_file(request, file, file_path, file_item, file_metadata, user):
try:
if file.content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
def process_uploaded_file(
request,
file,
file_path,
file_item,
file_metadata,
user,
db: Optional[Session] = None,
):
def _process_handler(db_session):
try:
if file.content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if strict_match_mime_type(stt_supported_content_types, file.content_type):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata, user)
if strict_match_mime_type(
stt_supported_content_types, file.content_type
):
file_path_processed = Storage.get_file(file_path)
result = transcribe(
request, file_path_processed, file_metadata, user
)
process_file(
request,
ProcessFileForm(
file_id=file_item.id, content=result.get("text", "")
),
user=user,
db=db_session,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(
request,
ProcessFileForm(file_id=file_item.id),
user=user,
db=db_session,
)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
process_file(
request,
ProcessFileForm(
file_id=file_item.id, content=result.get("text", "")
),
ProcessFileForm(file_id=file_item.id),
user=user,
db=db_session,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
except Exception as e:
log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id(
file_item.id,
{
"status": "failed",
"error": str(e.detail) if hasattr(e, "detail") else str(e),
},
)
except Exception as e:
log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id(
file_item.id,
{
"status": "failed",
"error": str(e.detail) if hasattr(e, "detail") else str(e),
},
db=db_session,
)
if db:
_process_handler(db)
else:
with SessionLocal() as db_session:
_process_handler(db_session)
@router.post("/", response_model=FileModelResponse)
@ -161,6 +198,7 @@ def upload_file(
process: bool = Query(True),
process_in_background: bool = Query(True),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
return upload_file_handler(
request,
@ -170,6 +208,7 @@ def upload_file(
process_in_background=process_in_background,
user=user,
background_tasks=background_tasks,
db=db,
)
@ -181,6 +220,7 @@ def upload_file_handler(
process_in_background: bool = Query(True),
user=Depends(get_verified_user),
background_tasks: Optional[BackgroundTasks] = None,
db: Optional[Session] = None,
):
log.info(f"file.content_type: {file.content_type} {process}")
@ -248,14 +288,17 @@ def upload_file_handler(
},
}
),
db=db,
)
if "channel_id" in file_metadata:
channel = Channels.get_channel_by_id_and_user_id(
file_metadata["channel_id"], user.id
file_metadata["channel_id"], user.id, db=db
)
if channel:
Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id)
Channels.add_file_to_channel_by_id(
channel.id, file_item.id, user.id, db=db
)
if process:
if background_tasks and process_in_background:
@ -277,6 +320,7 @@ def upload_file_handler(
file_item,
file_metadata,
user,
db=db,
)
return {"status": True, **file_item.model_dump()}
else:
@ -302,11 +346,15 @@ def upload_file_handler(
@router.get("/", response_model=list[FileModelResponse])
async def list_files(user=Depends(get_verified_user), content: bool = Query(True)):
async def list_files(
user=Depends(get_verified_user),
content: bool = Query(True),
db: Session = Depends(get_session),
):
if user.role == "admin":
files = Files.get_files()
files = Files.get_files(db=db)
else:
files = Files.get_files_by_user_id(user.id)
files = Files.get_files_by_user_id(user.id, db=db)
if not content:
for file in files:
@ -328,34 +376,41 @@ async def search_files(
description="Filename pattern to search for. Supports wildcards such as '*.txt'",
),
content: bool = Query(True),
skip: int = Query(0, ge=0, description="Number of files to skip"),
limit: int = Query(
100, ge=1, le=1000, description="Maximum number of files to return"
),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""
Search for files by filename with support for wildcard patterns.
Uses SQL-based filtering with pagination for better performance.
"""
# Get files according to user role
if user.role == "admin":
files = Files.get_files()
else:
files = Files.get_files_by_user_id(user.id)
# Determine user_id: null for admin (search all), user.id for regular users
user_id = None if user.role == "admin" else user.id
# Get matching files
matching_files = [
file for file in files if fnmatch(file.filename.lower(), filename.lower())
]
# Use optimized database query with pagination
files = Files.search_files(
user_id=user_id,
filename=filename,
skip=skip,
limit=limit,
db=db,
)
if not matching_files:
if not files:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No files found matching the pattern.",
)
if not content:
for file in matching_files:
if "content" in file.data:
for file in files:
if file.data and "content" in file.data:
del file.data["content"]
return matching_files
return files
############################
@ -364,8 +419,10 @@ async def search_files(
@router.delete("/all")
async def delete_all_files(user=Depends(get_admin_user)):
result = Files.delete_all_files()
async def delete_all_files(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
result = Files.delete_all_files(db=db)
if result:
try:
Storage.delete_all_files()
@ -391,8 +448,10 @@ async def delete_all_files(user=Depends(get_admin_user)):
@router.get("/{id}", response_model=Optional[FileModel])
async def get_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def get_file_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@ -403,7 +462,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
return file
else:
@ -415,9 +474,12 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/process/status")
async def get_file_process_status(
id: str, stream: bool = Query(False), user=Depends(get_verified_user)
id: str,
stream: bool = Query(False),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
file = Files.get_file_by_id(id)
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@ -428,7 +490,7 @@ async def get_file_process_status(
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
if stream:
MAX_FILE_PROCESSING_DURATION = 3600 * 2
@ -436,7 +498,7 @@ async def get_file_process_status(
async def event_stream(file_item):
if file_item:
for _ in range(MAX_FILE_PROCESSING_DURATION):
file_item = Files.get_file_by_id(file_item.id)
file_item = Files.get_file_by_id(file_item.id, db=db)
if file_item:
data = file_item.model_dump().get("data", {})
status = data.get("status")
@ -476,8 +538,10 @@ async def get_file_process_status(
@router.get("/{id}/data/content")
async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def get_file_data_content_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@ -488,7 +552,7 @@ async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
return {"content": file.data.get("content", "")}
else:
@ -509,9 +573,13 @@ class ContentForm(BaseModel):
@router.post("/{id}/data/content/update")
async def update_file_data_content_by_id(
request: Request, id: str, form_data: ContentForm, user=Depends(get_verified_user)
request: Request,
id: str,
form_data: ContentForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
file = Files.get_file_by_id(id)
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@ -522,7 +590,7 @@ async def update_file_data_content_by_id(
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "write", user)
or has_access_to_file(id, "write", user, db=db)
):
try:
process_file(
@ -530,7 +598,7 @@ async def update_file_data_content_by_id(
ProcessFileForm(file_id=id, content=form_data.content),
user=user,
)
file = Files.get_file_by_id(id=id)
file = Files.get_file_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
log.error(f"Error processing file: {file.id}")
@ -550,9 +618,12 @@ async def update_file_data_content_by_id(
@router.get("/{id}/content")
async def get_file_content_by_id(
id: str, user=Depends(get_verified_user), attachment: bool = Query(False)
id: str,
user=Depends(get_verified_user),
attachment: bool = Query(False),
db: Session = Depends(get_session),
):
file = Files.get_file_by_id(id)
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@ -563,7 +634,7 @@ async def get_file_content_by_id(
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
try:
file_path = Storage.get_file(file.path)
@ -619,8 +690,10 @@ async def get_file_content_by_id(
@router.get("/{id}/content/html")
async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def get_html_file_content_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@ -628,7 +701,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.NOT_FOUND,
)
file_user = Users.get_user_by_id(file.user_id)
file_user = Users.get_user_by_id(file.user_id, db=db)
if not file_user.role == "admin":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -638,7 +711,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
try:
file_path = Storage.get_file(file.path)
@ -668,8 +741,10 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/content/{file_name}")
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def get_file_content_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@ -680,7 +755,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
file_path = file.path
@ -730,8 +805,10 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.delete("/{id}")
async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def delete_file_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@ -742,10 +819,10 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "write", user)
or has_access_to_file(id, "write", user, db=db)
):
result = Files.delete_file_by_id(id)
result = Files.delete_file_by_id(id, db=db)
if result:
try:
Storage.delete_file(file.path)

View file

@ -22,6 +22,8 @@ from open_webui.models.knowledge import Knowledges
from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
@ -44,7 +46,11 @@ router = APIRouter()
@router.get("/", response_model=list[FolderNameIdResponse])
async def get_folders(request: Request, user=Depends(get_verified_user)):
async def get_folders(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if request.app.state.config.ENABLE_FOLDERS is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -55,22 +61,23 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
user.id,
"features.folders",
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folders = Folders.get_folders_by_user_id(user.id)
folders = Folders.get_folders_by_user_id(user.id, db=db)
# Verify folder data integrity
folder_list = []
for folder in folders:
if folder.parent_id and not Folders.get_folder_by_id_and_user_id(
folder.parent_id, user.id
folder.parent_id, user.id, db=db
):
folder = Folders.update_folder_parent_id_by_id_and_user_id(
folder.id, user.id, None
folder.id, user.id, None, db=db
)
if folder.data:
@ -80,12 +87,12 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
if file.get("type") == "file":
if Files.check_access_by_user_id(
file.get("id"), user.id, "read"
file.get("id"), user.id, "read", db=db
):
valid_files.append(file)
elif file.get("type") == "collection":
if Knowledges.check_access_by_user_id(
file.get("id"), user.id, "read"
file.get("id"), user.id, "read", db=db
):
valid_files.append(file)
else:
@ -93,7 +100,7 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
folder.data["files"] = valid_files
Folders.update_folder_by_id_and_user_id(
folder.id, user.id, FolderUpdateForm(data=folder.data)
folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
)
folder_list.append(FolderNameIdResponse(**folder.model_dump()))
@ -107,9 +114,13 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
@router.post("/")
def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
def create_folder(
form_data: FolderForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
None, user.id, form_data.name
None, user.id, form_data.name, db=db
)
if folder:
@ -119,7 +130,7 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
)
try:
folder = Folders.insert_new_folder(user.id, form_data)
folder = Folders.insert_new_folder(user.id, form_data, db=db)
return folder
except Exception as e:
log.exception(e)
@ -136,8 +147,10 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
@router.get("/{id}", response_model=Optional[FolderModel])
async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
async def get_folder_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
return folder
else:
@ -154,15 +167,18 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update")
async def update_folder_name_by_id(
id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user)
id: str,
form_data: FolderUpdateForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
if form_data.name is not None:
# Check if folder with same name exists
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name
folder.parent_id, user.id, form_data.name, db=db
)
if existing_folder and existing_folder.id != id:
raise HTTPException(
@ -171,7 +187,9 @@ async def update_folder_name_by_id(
)
try:
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
folder = Folders.update_folder_by_id_and_user_id(
id, user.id, form_data, db=db
)
return folder
except Exception as e:
log.exception(e)
@ -198,12 +216,15 @@ class FolderParentIdForm(BaseModel):
@router.post("/{id}/update/parent")
async def update_folder_parent_id_by_id(
id: str, form_data: FolderParentIdForm, user=Depends(get_verified_user)
id: str,
form_data: FolderParentIdForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
form_data.parent_id, user.id, folder.name
form_data.parent_id, user.id, folder.name, db=db
)
if existing_folder:
@ -214,7 +235,7 @@ async def update_folder_parent_id_by_id(
try:
folder = Folders.update_folder_parent_id_by_id_and_user_id(
id, user.id, form_data.parent_id
id, user.id, form_data.parent_id, db=db
)
return folder
except Exception as e:
@ -242,13 +263,16 @@ class FolderIsExpandedForm(BaseModel):
@router.post("/{id}/update/expanded")
async def update_folder_is_expanded_by_id(
id: str, form_data: FolderIsExpandedForm, user=Depends(get_verified_user)
id: str,
form_data: FolderIsExpandedForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
try:
folder = Folders.update_folder_is_expanded_by_id_and_user_id(
id, user.id, form_data.is_expanded
id, user.id, form_data.is_expanded, db=db
)
return folder
except Exception as e:
@ -276,10 +300,11 @@ async def delete_folder_by_id(
id: str,
delete_contents: Optional[bool] = True,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if Chats.count_chats_by_folder_id_and_user_id(id, user.id):
if Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db):
chat_delete_permission = has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS, db=db
)
if user.role != "admin" and not chat_delete_permission:
raise HTTPException(
@ -288,19 +313,21 @@ async def delete_folder_by_id(
)
folders = []
folders.append(Folders.get_folder_by_id_and_user_id(id, user.id))
folders.append(Folders.get_folder_by_id_and_user_id(id, user.id, db=db))
while folders:
folder = folders.pop()
if folder:
try:
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id, db=db)
for folder_id in folder_ids:
if delete_contents:
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
Chats.delete_chats_by_user_id_and_folder_id(
user.id, folder_id, db=db
)
else:
Chats.move_chats_by_user_id_and_folder_id(
user.id, folder_id, None
user.id, folder_id, None, db=db
)
return True
@ -314,7 +341,7 @@ async def delete_folder_by_id(
finally:
# Get all subfolders
subfolders = Folders.get_folders_by_parent_id_and_user_id(
folder.id, user.id
folder.id, user.id, db=db
)
folders.extend(subfolders)

View file

@ -6,6 +6,7 @@ import aiohttp
from pathlib import Path
from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.models.functions import (
FunctionForm,
FunctionModel,
@ -24,6 +25,8 @@ from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel, HttpUrl
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
@ -37,13 +40,17 @@ router = APIRouter()
@router.get("/", response_model=list[FunctionResponse])
async def get_functions(user=Depends(get_verified_user)):
return Functions.get_functions()
async def get_functions(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return Functions.get_functions(db=db)
@router.get("/list", response_model=list[FunctionUserResponse])
async def get_function_list(user=Depends(get_admin_user)):
return Functions.get_function_list()
async def get_function_list(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Functions.get_function_list(db=db)
############################
@ -52,8 +59,12 @@ async def get_function_list(user=Depends(get_admin_user)):
@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)):
return Functions.get_functions(include_valves=include_valves)
async def get_functions(
include_valves: bool = False,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
return Functions.get_functions(include_valves=include_valves, db=db)
############################
@ -110,7 +121,9 @@ async def load_function_from_url(
)
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.get(
url, headers={"Content-Type": "application/json"}
) as resp:
@ -142,7 +155,10 @@ class SyncFunctionsForm(BaseModel):
@router.post("/sync", response_model=list[FunctionWithValvesModel])
async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
request: Request,
form_data: SyncFunctionsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
for function in form_data.functions:
@ -164,7 +180,7 @@ async def sync_functions(
)
raise e
return Functions.sync_functions(user.id, form_data.functions)
return Functions.sync_functions(user.id, form_data.functions, db=db)
except Exception as e:
log.exception(f"Failed to load a function: {e}")
raise HTTPException(
@ -180,7 +196,10 @@ async def sync_functions(
@router.post("/create", response_model=Optional[FunctionResponse])
async def create_new_function(
request: Request, form_data: FunctionForm, user=Depends(get_admin_user)
request: Request,
form_data: FunctionForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
if not form_data.id.isidentifier():
raise HTTPException(
@ -190,7 +209,7 @@ async def create_new_function(
form_data.id = form_data.id.lower()
function = Functions.get_function_by_id(form_data.id)
function = Functions.get_function_by_id(form_data.id, db=db)
if function is None:
try:
form_data.content = replace_imports(form_data.content)
@ -203,13 +222,17 @@ async def create_new_function(
FUNCTIONS = request.app.state.FUNCTIONS
FUNCTIONS[form_data.id] = function_module
function = Functions.insert_new_function(user.id, function_type, form_data)
function = Functions.insert_new_function(
user.id, function_type, form_data, db=db
)
function_cache_dir = CACHE_DIR / "functions" / form_data.id
function_cache_dir.mkdir(parents=True, exist_ok=True)
if function_type == "filter" and getattr(function_module, "toggle", None):
Functions.update_function_metadata_by_id(id, {"toggle": True})
Functions.update_function_metadata_by_id(
form_data.id, {"toggle": True}, db=db
)
if function:
return function
@ -237,8 +260,10 @@ async def create_new_function(
@router.get("/id/{id}", response_model=Optional[FunctionModel])
async def get_function_by_id(id: str, user=Depends(get_admin_user)):
function = Functions.get_function_by_id(id)
async def get_function_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
return function
@ -255,11 +280,13 @@ async def get_function_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/toggle", response_model=Optional[FunctionModel])
async def toggle_function_by_id(id: str, user=Depends(get_admin_user)):
function = Functions.get_function_by_id(id)
async def toggle_function_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
function = Functions.update_function_by_id(
id, {"is_active": not function.is_active}
id, {"is_active": not function.is_active}, db=db
)
if function:
@ -282,11 +309,13 @@ async def toggle_function_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/toggle/global", response_model=Optional[FunctionModel])
async def toggle_global_by_id(id: str, user=Depends(get_admin_user)):
function = Functions.get_function_by_id(id)
async def toggle_global_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
function = Functions.update_function_by_id(
id, {"is_global": not function.is_global}
id, {"is_global": not function.is_global}, db=db
)
if function:
@ -310,7 +339,11 @@ async def toggle_global_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/update", response_model=Optional[FunctionModel])
async def update_function_by_id(
request: Request, id: str, form_data: FunctionForm, user=Depends(get_admin_user)
request: Request,
id: str,
form_data: FunctionForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
form_data.content = replace_imports(form_data.content)
@ -325,10 +358,10 @@ async def update_function_by_id(
updated = {**form_data.model_dump(exclude={"id"}), "type": function_type}
log.debug(updated)
function = Functions.update_function_by_id(id, updated)
function = Functions.update_function_by_id(id, updated, db=db)
if function_type == "filter" and getattr(function_module, "toggle", None):
Functions.update_function_metadata_by_id(id, {"toggle": True})
Functions.update_function_metadata_by_id(id, {"toggle": True}, db=db)
if function:
return function
@ -352,9 +385,12 @@ async def update_function_by_id(
@router.delete("/id/{id}/delete", response_model=bool)
async def delete_function_by_id(
request: Request, id: str, user=Depends(get_admin_user)
request: Request,
id: str,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
result = Functions.delete_function_by_id(id)
result = Functions.delete_function_by_id(id, db=db)
if result:
FUNCTIONS = request.app.state.FUNCTIONS
@ -370,11 +406,13 @@ async def delete_function_by_id(
@router.get("/id/{id}/valves", response_model=Optional[dict])
async def get_function_valves_by_id(id: str, user=Depends(get_admin_user)):
function = Functions.get_function_by_id(id)
async def get_function_valves_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
try:
valves = Functions.get_function_valves_by_id(id)
valves = Functions.get_function_valves_by_id(id, db=db)
return valves
except Exception as e:
raise HTTPException(
@ -395,9 +433,12 @@ async def get_function_valves_by_id(id: str, user=Depends(get_admin_user)):
@router.get("/id/{id}/valves/spec", response_model=Optional[dict])
async def get_function_valves_spec_by_id(
request: Request, id: str, user=Depends(get_admin_user)
request: Request,
id: str,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
function = Functions.get_function_by_id(id)
function = Functions.get_function_by_id(id, db=db)
if function:
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
@ -421,9 +462,13 @@ async def get_function_valves_spec_by_id(
@router.post("/id/{id}/valves/update", response_model=Optional[dict])
async def update_function_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_admin_user)
request: Request,
id: str,
form_data: dict,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
function = Functions.get_function_by_id(id)
function = Functions.get_function_by_id(id, db=db)
if function:
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
@ -437,7 +482,7 @@ async def update_function_valves_by_id(
valves = Valves(**form_data)
valves_dict = valves.model_dump(exclude_unset=True)
Functions.update_function_valves_by_id(id, valves_dict)
Functions.update_function_valves_by_id(id, valves_dict, db=db)
return valves_dict
except Exception as e:
log.exception(f"Error updating function values by id {id}: {e}")
@ -464,11 +509,15 @@ async def update_function_valves_by_id(
@router.get("/id/{id}/valves/user", response_model=Optional[dict])
async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user)):
function = Functions.get_function_by_id(id)
async def get_function_user_valves_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
try:
user_valves = Functions.get_user_valves_by_id_and_user_id(id, user.id)
user_valves = Functions.get_user_valves_by_id_and_user_id(
id, user.id, db=db
)
return user_valves
except Exception as e:
raise HTTPException(
@ -484,9 +533,12 @@ async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user
@router.get("/id/{id}/valves/user/spec", response_model=Optional[dict])
async def get_function_user_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user)
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
function = Functions.get_function_by_id(id)
function = Functions.get_function_by_id(id, db=db)
if function:
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
@ -505,9 +557,13 @@ async def get_function_user_valves_spec_by_id(
@router.post("/id/{id}/valves/user/update", response_model=Optional[dict])
async def update_function_user_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user)
request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
function = Functions.get_function_by_id(id)
function = Functions.get_function_by_id(id, db=db)
if function:
function_module, function_type, frontmatter = get_function_module_from_cache(
@ -522,7 +578,7 @@ async def update_function_user_valves_by_id(
user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True)
Functions.update_user_valves_by_id_and_user_id(
id, user.id, user_valves_dict
id, user.id, user_valves_dict, db=db
)
return user_valves_dict
except Exception as e:

View file

@ -16,6 +16,9 @@ from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.auth import get_admin_user, get_verified_user
@ -29,16 +32,21 @@ router = APIRouter()
@router.get("/", response_model=list[GroupResponse])
async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_user)):
async def get_groups(
share: Optional[bool] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
filter = {}
# Admins can share to all groups regardless of share setting
if user.role != "admin":
filter["member_id"] = user.id
if share is not None:
filter["share"] = share
if share is not None:
filter["share"] = share
groups = Groups.get_groups(filter=filter)
groups = Groups.get_groups(filter=filter, db=db)
return groups
@ -49,13 +57,17 @@ async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_use
@router.post("/create", response_model=Optional[GroupResponse])
async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
async def create_new_group(
form_data: GroupForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
group = Groups.insert_new_group(user.id, form_data)
group = Groups.insert_new_group(user.id, form_data, db=db)
if group:
return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
)
else:
raise HTTPException(
@ -76,12 +88,14 @@ async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
@router.get("/id/{id}", response_model=Optional[GroupResponse])
async def get_group_by_id(id: str, user=Depends(get_admin_user)):
group = Groups.get_group_by_id(id)
async def get_group_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
group = Groups.get_group_by_id(id, db=db)
if group:
return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
)
else:
raise HTTPException(
@ -101,13 +115,15 @@ class GroupExportResponse(GroupResponse):
@router.get("/id/{id}/export", response_model=Optional[GroupExportResponse])
async def export_group_by_id(id: str, user=Depends(get_admin_user)):
group = Groups.get_group_by_id(id)
async def export_group_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
group = Groups.get_group_by_id(id, db=db)
if group:
return GroupExportResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
user_ids=Groups.get_group_user_ids_by_id(group.id),
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
user_ids=Groups.get_group_user_ids_by_id(group.id, db=db),
)
else:
raise HTTPException(
@ -122,9 +138,11 @@ async def export_group_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/users", response_model=list[UserInfoResponse])
async def get_users_in_group(id: str, user=Depends(get_admin_user)):
async def get_users_in_group(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
try:
users = Users.get_users_by_group_id(id)
users = Users.get_users_by_group_id(id, db=db)
return users
except Exception as e:
log.exception(f"Error adding users to group {id}: {e}")
@ -141,14 +159,17 @@ async def get_users_in_group(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/update", response_model=Optional[GroupResponse])
async def update_group_by_id(
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
id: str,
form_data: GroupUpdateForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
group = Groups.update_group_by_id(id, form_data)
group = Groups.update_group_by_id(id, form_data, db=db)
if group:
return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
)
else:
raise HTTPException(
@ -170,17 +191,20 @@ async def update_group_by_id(
@router.post("/id/{id}/users/add", response_model=Optional[GroupResponse])
async def add_user_to_group(
id: str, form_data: UserIdsForm, user=Depends(get_admin_user)
id: str,
form_data: UserIdsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
if form_data.user_ids:
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids, db=db)
group = Groups.add_users_to_group(id, form_data.user_ids)
group = Groups.add_users_to_group(id, form_data.user_ids, db=db)
if group:
return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
)
else:
raise HTTPException(
@ -197,14 +221,17 @@ async def add_user_to_group(
@router.post("/id/{id}/users/remove", response_model=Optional[GroupResponse])
async def remove_users_from_group(
id: str, form_data: UserIdsForm, user=Depends(get_admin_user)
id: str,
form_data: UserIdsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
group = Groups.remove_users_from_group(id, form_data.user_ids)
group = Groups.remove_users_from_group(id, form_data.user_ids, db=db)
if group:
return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
)
else:
raise HTTPException(
@ -225,9 +252,11 @@ async def remove_users_from_group(
@router.delete("/id/{id}/delete", response_model=bool)
async def delete_group_by_id(id: str, user=Depends(get_admin_user)):
async def delete_group_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
try:
result = Groups.delete_group_by_id(id)
result = Groups.delete_group_by_id(id, db=db)
if result:
return result
else:

View file

@ -21,7 +21,10 @@ from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.models.chats import Chats
from open_webui.routers.files import upload_file_handler, get_file_content_by_id
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.headers import include_user_info_headers
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.images.comfyui import (
ComfyUICreateImageForm,
ComfyUIEditImageForm,
@ -461,6 +464,7 @@ class CreateImageForm(BaseModel):
prompt: str
size: Optional[str] = None
n: int = 1
steps: Optional[int] = None
negative_prompt: Optional[str] = None
@ -496,7 +500,7 @@ def get_image_data(data: str, headers=None):
return None, None
def upload_image(request, image_data, content_type, metadata, user):
def upload_image(request, image_data, content_type, metadata, user, db=None):
image_format = mimetypes.guess_extension(content_type)
file = UploadFile(
file=io.BytesIO(image_data),
@ -524,6 +528,7 @@ def upload_image(request, image_data, content_type, metadata, user):
message_id=message_id,
file_ids=[file_item.id],
user_id=user.id,
db=db,
)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
@ -534,6 +539,20 @@ def upload_image(request, image_data, content_type, metadata, user):
async def generate_images(
request: Request, form_data: CreateImageForm, user=Depends(get_verified_user)
):
if not request.app.state.config.ENABLE_IMAGE_GENERATION:
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != "admin" and not has_permission(
user.id, "features.image_generation", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return await image_generations(request, form_data, user=user)
@ -703,8 +722,15 @@ async def image_generations(
"n": form_data.n,
}
if request.app.state.config.IMAGE_STEPS is not None:
data["steps"] = request.app.state.config.IMAGE_STEPS
if (
request.app.state.config.IMAGE_STEPS is not None
or form_data.steps is not None
):
data["steps"] = (
form_data.steps
if form_data.steps is not None
else request.app.state.config.IMAGE_STEPS
)
if form_data.negative_prompt is not None:
data["negative_prompt"] = form_data.negative_prompt
@ -762,8 +788,15 @@ async def image_generations(
"height": height,
}
if request.app.state.config.IMAGE_STEPS is not None:
data["steps"] = request.app.state.config.IMAGE_STEPS
if (
request.app.state.config.IMAGE_STEPS is not None
or form_data.steps is not None
):
data["steps"] = (
form_data.steps
if form_data.steps is not None
else request.app.state.config.IMAGE_STEPS
)
if form_data.negative_prompt is not None:
data["negative_prompt"] = form_data.negative_prompt
@ -844,6 +877,9 @@ async def image_edits(
try:
async def load_url_image(data):
if data.startswith("data:"):
return data
if data.startswith("http://") or data.startswith("https://"):
r = await asyncio.to_thread(requests.get, data)
r.raise_for_status()
@ -851,10 +887,14 @@ async def image_edits(
image_data = base64.b64encode(r.content).decode("utf-8")
return f"data:{r.headers['content-type']};base64,{image_data}"
elif data.startswith("/api/v1/files"):
file_id = data.split("/api/v1/files/")[1].split("/content")[0]
file_response = await get_file_content_by_id(file_id, user)
else:
file_id = None
if data.startswith("/api/v1/files"):
file_id = data.split("/api/v1/files/")[1].split("/content")[0]
else:
file_id = data
file_response = await get_file_content_by_id(file_id, user)
if isinstance(file_response, FileResponse):
file_path = file_response.path
@ -864,7 +904,6 @@ async def image_edits(
mime_type, _ = mimetypes.guess_type(file_path)
return f"data:{mime_type};base64,{image_data}"
return data
# Load image(s) from URL(s) if necessary

View file

@ -1,9 +1,14 @@
from typing import List, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from fastapi.responses import StreamingResponse
from fastapi.concurrency import run_in_threadpool
import logging
import io
import zipfile
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.groups import Groups
from open_webui.models.knowledge import (
KnowledgeFileListResponse,
@ -23,7 +28,7 @@ from open_webui.routers.retrieval import (
from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_access, has_permission
@ -41,6 +46,54 @@ router = APIRouter()
PAGE_ITEM_COUNT = 30
############################
# Knowledge Base Embedding
############################
KNOWLEDGE_BASES_COLLECTION = "knowledge-bases"
async def embed_knowledge_base_metadata(
request: Request,
knowledge_base_id: str,
name: str,
description: str,
) -> bool:
"""Generate and store embedding for knowledge base."""
try:
content = f"{name}\n\n{description}" if description else name
embedding = await request.app.state.EMBEDDING_FUNCTION(content)
VECTOR_DB_CLIENT.upsert(
collection_name=KNOWLEDGE_BASES_COLLECTION,
items=[
{
"id": knowledge_base_id,
"text": content,
"vector": embedding,
"metadata": {
"knowledge_base_id": knowledge_base_id,
},
}
],
)
return True
except Exception as e:
log.error(f"Failed to embed knowledge base {knowledge_base_id}: {e}")
return False
def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool:
"""Remove knowledge base embedding."""
try:
VECTOR_DB_CLIENT.delete(
collection_name=KNOWLEDGE_BASES_COLLECTION,
ids=[knowledge_base_id],
)
return True
except Exception as e:
log.debug(f"Failed to remove embedding for {knowledge_base_id}: {e}")
return False
class KnowledgeAccessResponse(KnowledgeUserResponse):
write_access: Optional[bool] = False
@ -52,21 +105,25 @@ class KnowledgeAccessListResponse(BaseModel):
@router.get("/", response_model=KnowledgeAccessListResponse)
async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified_user)):
async def get_knowledge_bases(
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit
user.id, filter=filter, skip=skip, limit=limit, db=db
)
return KnowledgeAccessListResponse(
@ -75,7 +132,10 @@ async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified
**knowledge_base.model_dump(),
write_access=(
user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control)
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(
user.id, "write", knowledge_base.access_control, db=db
)
),
)
for knowledge_base in result.items
@ -90,6 +150,7 @@ async def search_knowledge_bases(
view_option: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
@ -102,14 +163,14 @@ async def search_knowledge_bases(
filter["view_option"] = view_option
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit
user.id, filter=filter, skip=skip, limit=limit, db=db
)
return KnowledgeAccessListResponse(
@ -118,7 +179,10 @@ async def search_knowledge_bases(
**knowledge_base.model_dump(),
write_access=(
user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control)
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(
user.id, "write", knowledge_base.access_control, db=db
)
),
)
for knowledge_base in result.items
@ -132,6 +196,7 @@ async def search_knowledge_files(
query: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
@ -141,13 +206,15 @@ async def search_knowledge_files(
if query:
filter["query"] = query
groups = Groups.get_groups_by_member_id(user.id)
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
return Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit)
return Knowledges.search_knowledge_files(
filter=filter, skip=skip, limit=limit, db=db
)
############################
@ -157,10 +224,13 @@ async def search_knowledge_files(
@router.post("/create", response_model=Optional[KnowledgeResponse])
async def create_new_knowledge(
request: Request, form_data: KnowledgeForm, user=Depends(get_verified_user)
request: Request,
form_data: KnowledgeForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -175,13 +245,21 @@ async def create_new_knowledge(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
form_data.access_control = {}
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
knowledge = Knowledges.insert_new_knowledge(user.id, form_data, db=db)
if knowledge:
# Embed knowledge base for semantic search
await embed_knowledge_base_metadata(
request,
knowledge.id,
knowledge.name,
knowledge.description,
)
return knowledge
else:
raise HTTPException(
@ -196,20 +274,24 @@ async def create_new_knowledge(
@router.post("/reindex", response_model=bool)
async def reindex_knowledge_files(request: Request, user=Depends(get_verified_user)):
async def reindex_knowledge_files(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
knowledge_bases = Knowledges.get_knowledge_bases()
knowledge_bases = Knowledges.get_knowledge_bases(db=db)
log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases")
for knowledge_base in knowledge_bases:
try:
files = Knowledges.get_files_by_id(knowledge_base.id)
files = Knowledges.get_files_by_id(knowledge_base.id, db=db)
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
VECTOR_DB_CLIENT.delete_collection(
@ -229,6 +311,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
file_id=file.id, collection_name=knowledge_base.id
),
user=user,
db=db,
)
except Exception as e:
log.error(
@ -253,6 +336,30 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
return True
############################
# ReindexKnowledgeBases
############################
@router.post("/metadata/reindex", response_model=dict)
async def reindex_knowledge_base_metadata_embeddings(
request: Request,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Batch embed all existing knowledge bases. Admin only."""
knowledge_bases = Knowledges.get_knowledge_bases(db=db)
log.info(f"Reindexing embeddings for {len(knowledge_bases)} knowledge bases")
success_count = 0
for kb in knowledge_bases:
if await embed_knowledge_base_metadata(request, kb.id, kb.name, kb.description):
success_count += 1
log.info(f"Embedding reindex complete: {success_count}/{len(knowledge_bases)}")
return {"total": len(knowledge_bases), "success": success_count}
############################
# GetKnowledgeById
############################
@ -264,26 +371,34 @@ class KnowledgeFilesResponse(KnowledgeResponse):
@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
knowledge = Knowledges.get_knowledge_by_id(id=id)
async def get_knowledge_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if knowledge:
if (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control)
or has_access(user.id, "read", knowledge.access_control, db=db)
):
return KnowledgeFilesResponse(
**knowledge.model_dump(),
write_access=(
user.id == knowledge.user_id
or has_access(user.id, "write", knowledge.access_control)
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(user.id, "write", knowledge.access_control, db=db)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
@ -299,8 +414,9 @@ async def update_knowledge_by_id(
id: str,
form_data: KnowledgeForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -309,7 +425,7 @@ async def update_knowledge_by_id(
# Is the user the original creator, in a group with write access, or an admin
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -325,15 +441,23 @@ async def update_knowledge_by_id(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
form_data.access_control = {}
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data, db=db)
if knowledge:
# Re-embed knowledge base for semantic search
await embed_knowledge_base_metadata(
request,
knowledge.id,
knowledge.name,
knowledge.description,
)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
)
else:
raise HTTPException(
@ -356,9 +480,10 @@ async def get_knowledge_files_by_id(
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -368,7 +493,7 @@ async def get_knowledge_files_by_id(
if not (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control)
or has_access(user.id, "read", knowledge.access_control, db=db)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -391,7 +516,7 @@ async def get_knowledge_files_by_id(
filter["direction"] = direction
return Knowledges.search_files_by_id(
id, user.id, filter=filter, skip=skip, limit=limit
id, user.id, filter=filter, skip=skip, limit=limit, db=db
)
@ -410,8 +535,9 @@ def add_file_to_knowledge_by_id(
id: str,
form_data: KnowledgeFileIdForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -420,7 +546,7 @@ def add_file_to_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -428,7 +554,7 @@ def add_file_to_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
file = Files.get_file_by_id(form_data.file_id)
file = Files.get_file_by_id(form_data.file_id, db=db)
if not file:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -446,11 +572,12 @@ def add_file_to_knowledge_by_id(
request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user,
db=db,
)
# Add file to knowledge base
Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id, user_id=user.id
knowledge_id=id, file_id=form_data.file_id, user_id=user.id, db=db
)
except Exception as e:
log.debug(e)
@ -462,7 +589,7 @@ def add_file_to_knowledge_by_id(
if knowledge:
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
)
else:
raise HTTPException(
@ -477,8 +604,9 @@ def update_file_from_knowledge_by_id(
id: str,
form_data: KnowledgeFileIdForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -487,7 +615,7 @@ def update_file_from_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin"
):
@ -496,7 +624,7 @@ def update_file_from_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
file = Files.get_file_by_id(form_data.file_id)
file = Files.get_file_by_id(form_data.file_id, db=db)
if not file:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -514,6 +642,7 @@ def update_file_from_knowledge_by_id(
request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user,
db=db,
)
except Exception as e:
raise HTTPException(
@ -524,7 +653,7 @@ def update_file_from_knowledge_by_id(
if knowledge:
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
)
else:
raise HTTPException(
@ -544,8 +673,9 @@ def remove_file_from_knowledge_by_id(
form_data: KnowledgeFileIdForm,
delete_file: bool = Query(True),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -554,7 +684,7 @@ def remove_file_from_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -562,7 +692,7 @@ def remove_file_from_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
file = Files.get_file_by_id(form_data.file_id)
file = Files.get_file_by_id(form_data.file_id, db=db)
if not file:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -570,7 +700,7 @@ def remove_file_from_knowledge_by_id(
)
Knowledges.remove_file_from_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id
knowledge_id=id, file_id=form_data.file_id, db=db
)
# Remove content from the vector database
@ -599,12 +729,12 @@ def remove_file_from_knowledge_by_id(
pass
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
Files.delete_file_by_id(form_data.file_id, db=db)
if knowledge:
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
)
else:
raise HTTPException(
@ -619,8 +749,10 @@ def remove_file_from_knowledge_by_id(
@router.delete("/{id}/delete", response_model=bool)
async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
knowledge = Knowledges.get_knowledge_by_id(id=id)
async def delete_knowledge_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -629,7 +761,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -640,7 +772,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
log.info(f"Deleting knowledge base: {id} (name: {knowledge.name})")
# Get all models
models = Models.get_all_models()
models = Models.get_all_models(db=db)
log.info(f"Found {len(models)} models to check for knowledge base {id}")
# Update models that reference this knowledge base
@ -664,7 +796,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
access_control=model.access_control,
is_active=model.is_active,
)
Models.update_model_by_id(model.id, model_form)
Models.update_model_by_id(model.id, model_form, db=db)
# Clean up vector DB
try:
@ -672,7 +804,11 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
except Exception as e:
log.debug(e)
pass
result = Knowledges.delete_knowledge_by_id(id=id)
# Remove knowledge base embedding
remove_knowledge_base_metadata_embedding(id)
result = Knowledges.delete_knowledge_by_id(id=id, db=db)
return result
@ -682,8 +818,10 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/reset", response_model=Optional[KnowledgeResponse])
async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
knowledge = Knowledges.get_knowledge_by_id(id=id)
async def reset_knowledge_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -692,7 +830,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -706,7 +844,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
log.debug(e)
pass
knowledge = Knowledges.reset_knowledge_by_id(id=id)
knowledge = Knowledges.reset_knowledge_by_id(id=id, db=db)
return knowledge
@ -721,11 +859,12 @@ async def add_files_to_knowledge_batch(
id: str,
form_data: list[KnowledgeFileIdForm],
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""
Add multiple files to a knowledge base
"""
knowledge = Knowledges.get_knowledge_by_id(id=id)
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -734,7 +873,7 @@ async def add_files_to_knowledge_batch(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -746,7 +885,7 @@ async def add_files_to_knowledge_batch(
log.info(f"files/batch/add - {len(form_data)} files")
files: List[FileModel] = []
for form in form_data:
file = Files.get_file_by_id(form.file_id)
file = Files.get_file_by_id(form.file_id, db=db)
if not file:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -760,6 +899,7 @@ async def add_files_to_knowledge_batch(
request=request,
form_data=BatchProcessFilesForm(files=files, collection_name=id),
user=user,
db=db,
)
except Exception as e:
log.error(
@ -771,7 +911,7 @@ async def add_files_to_knowledge_batch(
successful_file_ids = [r.file_id for r in result.results if r.status == "completed"]
for file_id in successful_file_ids:
Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=file_id, user_id=user.id
knowledge_id=id, file_id=file_id, user_id=user.id, db=db
)
# If there were any errors, include them in the response
@ -779,7 +919,7 @@ async def add_files_to_knowledge_batch(
error_details = [f"{err.file_id}: {err.error}" for err in result.errors]
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
warnings={
"message": "Some files failed to process",
"errors": error_details,
@ -788,5 +928,53 @@ async def add_files_to_knowledge_batch(
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
)
############################
# ExportKnowledgeById
############################
@router.get("/{id}/export")
async def export_knowledge_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
"""
Export a knowledge base as a zip file containing .txt files.
Admin only.
"""
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
files = Knowledges.get_files_by_id(id, db=db)
# Create zip file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for file in files:
content = file.data.get("content", "") if file.data else ""
if content:
# Use original filename with .txt extension
filename = file.filename
if not filename.endswith(".txt"):
filename = f"{filename}.txt"
zf.writestr(filename, content)
zip_buffer.seek(0)
# Sanitize knowledge name for filename
safe_name = "".join(c if c.isalnum() or c in " -_" else "_" for c in knowledge.name)
zip_filename = f"{safe_name}.zip"
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
)

View file

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
import logging
import asyncio
@ -7,7 +7,11 @@ from typing import Optional
from open_webui.models.memories import Memories, MemoryModel
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.utils.auth import get_verified_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.access_control import has_permission
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
@ -25,8 +29,26 @@ async def get_embeddings(request: Request):
@router.get("/", response_model=list[MemoryModel])
async def get_memories(user=Depends(get_verified_user)):
return Memories.get_memories_by_user_id(user.id)
async def get_memories(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return Memories.get_memories_by_user_id(user.id, db=db)
############################
@ -47,8 +69,23 @@ async def add_memory(
request: Request,
form_data: AddMemoryForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
memory = Memories.insert_new_memory(user.id, form_data.content)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memory = Memories.insert_new_memory(user.id, form_data.content, db=db)
vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
@ -79,9 +116,26 @@ class QueryMemoryForm(BaseModel):
@router.post("/query")
async def query_memory(
request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user)
request: Request,
form_data: QueryMemoryForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
memories = Memories.get_memories_by_user_id(user.id)
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memories = Memories.get_memories_by_user_id(user.id, db=db)
if not memories:
raise HTTPException(status_code=404, detail="No memories found for user")
@ -101,11 +155,27 @@ async def query_memory(
############################
@router.post("/reset", response_model=bool)
async def reset_memory_from_vector_db(
request: Request, user=Depends(get_verified_user)
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}")
memories = Memories.get_memories_by_user_id(user.id)
memories = Memories.get_memories_by_user_id(user.id, db=db)
# Generate vectors in parallel
vectors = await asyncio.gather(
@ -140,8 +210,26 @@ async def reset_memory_from_vector_db(
@router.delete("/delete/user", response_model=bool)
async def delete_memory_by_user_id(user=Depends(get_verified_user)):
result = Memories.delete_memories_by_user_id(user.id)
async def delete_memory_by_user_id(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Memories.delete_memories_by_user_id(user.id, db=db)
if result:
try:
@ -164,9 +252,24 @@ async def update_memory_by_id(
request: Request,
form_data: MemoryUpdateModel,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memory = Memories.update_memory_by_id_and_user_id(
memory_id, user.id, form_data.content
memory_id, user.id, form_data.content, db=db
)
if memory is None:
raise HTTPException(status_code=404, detail="Memory not found")
@ -198,8 +301,27 @@ async def update_memory_by_id(
@router.delete("/{memory_id}", response_model=bool)
async def delete_memory_by_id(memory_id: str, user=Depends(get_verified_user)):
result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id)
async def delete_memory_by_id(
memory_id: str,
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db)
if result:
VECTOR_DB_CLIENT.delete(

View file

@ -11,6 +11,8 @@ from open_webui.models.models import (
ModelModel,
ModelResponse,
ModelListResponse,
ModelAccessListResponse,
ModelAccessResponse,
Models,
)
@ -30,6 +32,8 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
@ -49,7 +53,7 @@ PAGE_ITEM_COUNT = 30
@router.get(
"/list", response_model=ModelListResponse
"/list", response_model=ModelAccessListResponse
) # do NOT use "/" as path, conflicts with main.py
async def get_models(
query: Optional[str] = None,
@ -59,6 +63,7 @@ async def get_models(
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
@ -79,13 +84,27 @@ async def get_models(
filter["direction"] = direction
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
return Models.search_models(user.id, filter=filter, skip=skip, limit=limit)
result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db)
return ModelAccessListResponse(
items=[
ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
),
)
for model in result.items
],
total=result.total,
)
###########################
@ -94,8 +113,10 @@ async def get_models(
@router.get("/base", response_model=list[ModelResponse])
async def get_base_models(user=Depends(get_admin_user)):
return Models.get_base_models()
async def get_base_models(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Models.get_base_models(db=db)
###########################
@ -104,11 +125,13 @@ async def get_base_models(user=Depends(get_admin_user)):
@router.get("/tags", response_model=list[str])
async def get_model_tags(user=Depends(get_verified_user)):
async def get_model_tags(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
models = Models.get_models()
models = Models.get_models(db=db)
else:
models = Models.get_models_by_user_id(user.id)
models = Models.get_models_by_user_id(user.id, db=db)
tags_set = set()
for model in models:
@ -132,16 +155,17 @@ async def create_new_model(
request: Request,
form_data: ModelForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "workspace.models", request.app.state.config.USER_PERMISSIONS
user.id, "workspace.models", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
model = Models.get_model_by_id(form_data.id)
model = Models.get_model_by_id(form_data.id, db=db)
if model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -155,7 +179,7 @@ async def create_new_model(
)
else:
model = Models.insert_new_model(form_data, user.id)
model = Models.insert_new_model(form_data, user.id, db=db)
if model:
return model
else:
@ -171,9 +195,16 @@ async def create_new_model(
@router.get("/export", response_model=list[ModelModel])
async def export_models(request: Request, user=Depends(get_verified_user)):
async def export_models(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "workspace.models_export", request.app.state.config.USER_PERMISSIONS
user.id,
"workspace.models_export",
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -181,9 +212,9 @@ async def export_models(request: Request, user=Depends(get_verified_user)):
)
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return Models.get_models()
return Models.get_models(db=db)
else:
return Models.get_models_by_user_id(user.id)
return Models.get_models_by_user_id(user.id, db=db)
############################
@ -200,9 +231,13 @@ async def import_models(
request: Request,
user=Depends(get_verified_user),
form_data: ModelsImportForm = (...),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "workspace.models_import", request.app.state.config.USER_PERMISSIONS
user.id,
"workspace.models_import",
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -216,7 +251,7 @@ async def import_models(
model_id = model_data.get("id")
if model_id and is_valid_model_id(model_id):
existing_model = Models.get_model_by_id(model_id)
existing_model = Models.get_model_by_id(model_id, db=db)
if existing_model:
# Update existing model
model_data["meta"] = model_data.get("meta", {})
@ -225,13 +260,15 @@ async def import_models(
updated_model = ModelForm(
**{**existing_model.model_dump(), **model_data}
)
Models.update_model_by_id(model_id, updated_model)
Models.update_model_by_id(model_id, updated_model, db=db)
else:
# Insert new model
model_data["meta"] = model_data.get("meta", {})
model_data["params"] = model_data.get("params", {})
new_model = ModelForm(**model_data)
Models.insert_new_model(user_id=user.id, form_data=new_model)
Models.insert_new_model(
user_id=user.id, form_data=new_model, db=db
)
return True
else:
raise HTTPException(status_code=400, detail="Invalid JSON format")
@ -251,9 +288,12 @@ class SyncModelsForm(BaseModel):
@router.post("/sync", response_model=list[ModelModel])
async def sync_models(
request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user)
request: Request,
form_data: SyncModelsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
return Models.sync_models(user.id, form_data.models)
return Models.sync_models(user.id, form_data.models, db=db)
###########################
@ -266,19 +306,33 @@ class ModelIdForm(BaseModel):
# Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id
@router.get("/model", response_model=Optional[ModelResponse])
async def get_model_by_id(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
@router.get("/model", response_model=Optional[ModelAccessResponse])
async def get_model_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
model = Models.get_model_by_id(id, db=db)
if model:
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or model.user_id == user.id
or has_access(user.id, "read", model.access_control)
or has_access(user.id, "read", model.access_control, db=db)
):
return model
return ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
@ -289,38 +343,42 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/model/profile/image")
async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
# Cache-control headers to prevent stale cached images
cache_headers = {"Cache-Control": "no-cache, must-revalidate"}
def get_model_profile_image(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
model = Models.get_model_by_id(id, db=db)
if model:
etag = f'"{model.updated_at}"' if model.updated_at else None
if model.meta.profile_image_url:
if model.meta.profile_image_url.startswith("http"):
return Response(
status_code=status.HTTP_302_FOUND,
headers={"Location": model.meta.profile_image_url, **cache_headers},
headers={"Location": model.meta.profile_image_url},
)
elif model.meta.profile_image_url.startswith("data:image"):
try:
header, base64_data = model.meta.profile_image_url.split(",", 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
media_type = header.split(";")[0].lstrip("data:")
headers = {"Content-Disposition": "inline"}
if etag:
headers["ETag"] = etag
return StreamingResponse(
image_buffer,
media_type="image/png",
headers={
"Content-Disposition": "inline; filename=image.png",
**cache_headers,
},
media_type=media_type,
headers=headers,
)
except Exception as e:
pass
return FileResponse(f"{STATIC_DIR}/favicon.png", headers=cache_headers)
return FileResponse(f"{STATIC_DIR}/favicon.png")
else:
return FileResponse(f"{STATIC_DIR}/favicon.png", headers=cache_headers)
return FileResponse(f"{STATIC_DIR}/favicon.png")
############################
@ -329,15 +387,17 @@ async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
@router.post("/model/toggle", response_model=Optional[ModelResponse])
async def toggle_model_by_id(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
async def toggle_model_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
model = Models.get_model_by_id(id, db=db)
if model:
if (
user.role == "admin"
or model.user_id == user.id
or has_access(user.id, "write", model.access_control)
or has_access(user.id, "write", model.access_control, db=db)
):
model = Models.toggle_model_by_id(id)
model = Models.toggle_model_by_id(id, db=db)
if model:
return model
@ -367,8 +427,9 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user)):
async def update_model_by_id(
form_data: ModelForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
model = Models.get_model_by_id(form_data.id)
model = Models.get_model_by_id(form_data.id, db=db)
if not model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -377,7 +438,7 @@ async def update_model_by_id(
if (
model.user_id != user.id
and not has_access(user.id, "write", model.access_control)
and not has_access(user.id, "write", model.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -385,7 +446,9 @@ async def update_model_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()))
model = Models.update_model_by_id(
form_data.id, ModelForm(**form_data.model_dump()), db=db
)
return model
@ -395,8 +458,12 @@ async def update_model_by_id(
@router.post("/model/delete", response_model=bool)
async def delete_model_by_id(form_data: ModelIdForm, user=Depends(get_verified_user)):
model = Models.get_model_by_id(form_data.id)
async def delete_model_by_id(
form_data: ModelIdForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
model = Models.get_model_by_id(form_data.id, db=db)
if not model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -406,18 +473,20 @@ async def delete_model_by_id(form_data: ModelIdForm, user=Depends(get_verified_u
if (
user.role != "admin"
and model.user_id != user.id
and not has_access(user.id, "write", model.access_control)
and not has_access(user.id, "write", model.access_control, db=db)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
result = Models.delete_model_by_id(form_data.id)
result = Models.delete_model_by_id(form_data.id, db=db)
return result
@router.delete("/delete/all", response_model=bool)
async def delete_all_models(user=Depends(get_admin_user)):
result = Models.delete_all_models()
async def delete_all_models(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
result = Models.delete_all_models(db=db)
return result

View file

@ -28,6 +28,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
@ -49,10 +51,13 @@ class NoteItemResponse(BaseModel):
@router.get("/", response_model=list[NoteItemResponse])
async def get_notes(
request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
request: Request,
page: Optional[int] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -65,16 +70,23 @@ async def get_notes(
limit = 60
skip = (page - 1) * limit
notes = [
notes = Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit, db=db)
if not notes:
return []
user_ids = list(set(note.user_id for note in notes))
users = {user.id: user for user in Users.get_users_by_user_ids(user_ids, db=db)}
return [
NoteUserResponse(
**{
**note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
"user": UserResponse(**users[note.user_id].model_dump()),
}
)
for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit)
for note in notes
if note.user_id in users
]
return notes
@router.get("/search", response_model=NoteListResponse)
@ -87,9 +99,10 @@ async def search_notes(
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -115,13 +128,13 @@ async def search_notes(
filter["direction"] = direction
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
return Notes.search_notes(user.id, filter, skip=skip, limit=limit)
return Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
############################
@ -131,10 +144,13 @@ async def search_notes(
@router.post("/create", response_model=Optional[NoteModel])
async def create_new_note(
request: Request, form_data: NoteForm, user=Depends(get_verified_user)
request: Request,
form_data: NoteForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -142,7 +158,7 @@ async def create_new_note(
)
try:
note = Notes.insert_new_note(form_data, user.id)
note = Notes.insert_new_note(user.id, form_data, db=db)
return note
except Exception as e:
log.exception(e)
@ -161,16 +177,21 @@ class NoteResponse(NoteModel):
@router.get("/{id}", response_model=Optional[NoteResponse])
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
async def get_note_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = Notes.get_note_by_id(id)
note = Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
@ -178,7 +199,11 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
if user.role != "admin" and (
user.id != note.user_id
and (not has_access(user.id, type="read", access_control=note.access_control))
and (
not has_access(
user.id, type="read", access_control=note.access_control, db=db
)
)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -188,7 +213,11 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
user.role == "admin"
or (user.id == note.user_id)
or has_access(
user.id, type="write", access_control=note.access_control, strict=False
user.id,
type="write",
access_control=note.access_control,
strict=False,
db=db,
)
)
@ -202,17 +231,21 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
@router.post("/{id}/update", response_model=Optional[NoteModel])
async def update_note_by_id(
request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user)
request: Request,
id: str,
form_data: NoteForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = Notes.get_note_by_id(id)
note = Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
@ -220,7 +253,9 @@ async def update_note_by_id(
if user.role != "admin" and (
user.id != note.user_id
and not has_access(user.id, type="write", access_control=note.access_control)
and not has_access(
user.id, type="write", access_control=note.access_control, db=db
)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -234,12 +269,13 @@ async def update_note_by_id(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
form_data.access_control = {}
try:
note = Notes.update_note_by_id(id, form_data)
note = Notes.update_note_by_id(id, form_data, db=db)
await sio.emit(
"note-events",
note.model_dump(),
@ -260,16 +296,21 @@ async def update_note_by_id(
@router.delete("/{id}/delete", response_model=bool)
async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
async def delete_note_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = Notes.get_note_by_id(id)
note = Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
@ -277,14 +318,16 @@ async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified
if user.role != "admin" and (
user.id != note.user_id
and not has_access(user.id, type="write", access_control=note.access_control)
and not has_access(
user.id, type="write", access_control=note.access_control, db=db
)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
note = Notes.delete_note_by_id(id)
note = Notes.delete_note_by_id(id, db=db)
return True
except Exception as e:
log.exception(e)

View file

@ -38,6 +38,9 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, validator
from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.models import Models
@ -421,14 +424,14 @@ async def get_all_models(request: Request, user: UserModel = None):
return models
async def get_filtered_models(models, user):
async def get_filtered_models(models, user, db=None):
# Filter models based on user access control
filtered_models = []
for model in models.get("models", []):
model_info = Models.get_model_by_id(model["model"])
model_info = Models.get_model_by_id(model["model"], db=db)
if model_info:
if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control
user.id, type="read", access_control=model_info.access_control, db=db
):
filtered_models.append(model)
return filtered_models
@ -1253,6 +1256,8 @@ async def generate_chat_completion(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
bypass_filter: Optional[bool] = False,
bypass_system_prompt: bool = False,
db: Session = Depends(get_session),
):
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
@ -1274,7 +1279,7 @@ async def generate_chat_completion(
del payload["metadata"]
model_id = payload["model"]
model_info = Models.get_model_by_id(model_id)
model_info = Models.get_model_by_id(model_id, db=db)
if model_info:
if model_info.base_model_id:
@ -1291,14 +1296,18 @@ async def generate_chat_completion(
system = params.pop("system", None)
payload = apply_model_params_to_body_ollama(params, payload)
payload = apply_system_prompt_to_body(system, payload, metadata, user)
if not bypass_system_prompt:
payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
if not (
user.id == model_info.user_id
or has_access(
user.id, type="read", access_control=model_info.access_control
user.id,
type="read",
access_control=model_info.access_control,
db=db,
)
):
raise HTTPException(
@ -1370,6 +1379,7 @@ async def generate_openai_completion(
form_data: dict,
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
metadata = form_data.pop("metadata", None)
@ -1390,7 +1400,7 @@ async def generate_openai_completion(
if ":" not in model_id:
model_id = f"{model_id}:latest"
model_info = Models.get_model_by_id(model_id)
model_info = Models.get_model_by_id(model_id, db=db)
if model_info:
if model_info.base_model_id:
payload["model"] = model_info.base_model_id
@ -1404,7 +1414,10 @@ async def generate_openai_completion(
if not (
user.id == model_info.user_id
or has_access(
user.id, type="read", access_control=model_info.access_control
user.id,
type="read",
access_control=model_info.access_control,
db=db,
)
):
raise HTTPException(
@ -1449,6 +1462,7 @@ async def generate_openai_chat_completion(
form_data: dict,
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
metadata = form_data.pop("metadata", None)
@ -1469,7 +1483,7 @@ async def generate_openai_chat_completion(
if ":" not in model_id:
model_id = f"{model_id}:latest"
model_info = Models.get_model_by_id(model_id)
model_info = Models.get_model_by_id(model_id, db=db)
if model_info:
if model_info.base_model_id:
payload["model"] = model_info.base_model_id
@ -1487,7 +1501,10 @@ async def generate_openai_chat_completion(
if not (
user.id == model_info.user_id
or has_access(
user.id, type="read", access_control=model_info.access_control
user.id,
type="read",
access_control=model_info.access_control,
db=db,
)
):
raise HTTPException(
@ -1530,6 +1547,7 @@ async def get_openai_models(
request: Request,
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
models = []
@ -1582,10 +1600,13 @@ async def get_openai_models(
# Filter models based on user access control
filtered_models = []
for model in models:
model_info = Models.get_model_by_id(model["id"])
model_info = Models.get_model_by_id(model["id"], db=db)
if model_info:
if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control
user.id,
type="read",
access_control=model_info.access_control,
db=db,
):
filtered_models.append(model)
models = filtered_models
@ -1652,11 +1673,10 @@ async def download_file_stream(
if done:
file.close()
hashed = calculate_sha256(file_path, chunk_size)
with open(file_path, "rb") as file:
chunk_size = 1024 * 1024 * 2
hashed = calculate_sha256(file, chunk_size)
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
with requests.Session() as session:
response = session.post(url, data=file, timeout=30)

View file

@ -19,6 +19,9 @@ from fastapi.responses import (
)
from pydantic import BaseModel
from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.models import Models
from open_webui.config import (
@ -453,14 +456,14 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
return responses
async def get_filtered_models(models, user):
async def get_filtered_models(models, user, db=None):
# Filter models based on user access control
filtered_models = []
for model in models.get("data", []):
model_info = Models.get_model_by_id(model["id"])
model_info = Models.get_model_by_id(model["id"], db=db)
if model_info:
if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control
user.id, type="read", access_control=model_info.access_control, db=db
):
filtered_models.append(model)
return filtered_models
@ -797,6 +800,8 @@ async def generate_chat_completion(
form_data: dict,
user=Depends(get_verified_user),
bypass_filter: Optional[bool] = False,
bypass_system_prompt: bool = False,
db: Session = Depends(get_session),
):
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
@ -807,7 +812,7 @@ async def generate_chat_completion(
metadata = payload.pop("metadata", None)
model_id = form_data.get("model")
model_info = Models.get_model_by_id(model_id)
model_info = Models.get_model_by_id(model_id, db=db)
# Check model info and override the payload
if model_info:
@ -826,14 +831,18 @@ async def generate_chat_completion(
system = params.pop("system", None)
payload = apply_model_params_to_body_openai(params, payload)
payload = apply_system_prompt_to_body(system, payload, metadata, user)
if not bypass_system_prompt:
payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
if not (
user.id == model_info.user_id
or has_access(
user.id, type="read", access_control=model_info.access_control
user.id,
type="read",
access_control=model_info.access_control,
db=db,
)
):
raise HTTPException(

View file

@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request
from open_webui.models.prompts import (
PromptForm,
PromptUserResponse,
PromptAccessResponse,
PromptModel,
Prompts,
)
@ -11,6 +12,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
router = APIRouter()
@ -20,23 +23,37 @@ router = APIRouter()
@router.get("/", response_model=list[PromptModel])
async def get_prompts(user=Depends(get_verified_user)):
async def get_prompts(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts()
prompts = Prompts.get_prompts(db=db)
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read")
prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
return prompts
@router.get("/list", response_model=list[PromptUserResponse])
async def get_prompt_list(user=Depends(get_verified_user)):
@router.get("/list", response_model=list[PromptAccessResponse])
async def get_prompt_list(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts()
prompts = Prompts.get_prompts(db=db)
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "write")
prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
return prompts
return [
PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
for prompt in prompts
]
############################
@ -46,16 +63,23 @@ async def get_prompt_list(user=Depends(get_verified_user)):
@router.post("/create", response_model=Optional[PromptModel])
async def create_new_prompt(
request: Request, form_data: PromptForm, user=Depends(get_verified_user)
request: Request,
form_data: PromptForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not (
has_permission(
user.id, "workspace.prompts", request.app.state.config.USER_PERMISSIONS
user.id,
"workspace.prompts",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
or has_permission(
user.id,
"workspace.prompts_import",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
raise HTTPException(
@ -63,9 +87,9 @@ async def create_new_prompt(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
prompt = Prompts.get_prompt_by_command(form_data.command)
prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
if prompt is None:
prompt = Prompts.insert_new_prompt(user.id, form_data)
prompt = Prompts.insert_new_prompt(user.id, form_data, db=db)
if prompt:
return prompt
@ -84,17 +108,26 @@ async def create_new_prompt(
############################
@router.get("/command/{command}", response_model=Optional[PromptModel])
async def get_prompt_by_command(command: str, user=Depends(get_verified_user)):
prompt = Prompts.get_prompt_by_command(f"/{command}")
@router.get("/command/{command}", response_model=Optional[PromptAccessResponse])
async def get_prompt_by_command(
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
if prompt:
if (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control)
or has_access(user.id, "read", prompt.access_control, db=db)
):
return prompt
return PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -112,8 +145,9 @@ async def update_prompt_by_command(
command: str,
form_data: PromptForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
prompt = Prompts.get_prompt_by_command(f"/{command}")
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -123,7 +157,7 @@ async def update_prompt_by_command(
# Is the user the original creator, in a group with write access, or an admin
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control)
and not has_access(user.id, "write", prompt.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -131,7 +165,7 @@ async def update_prompt_by_command(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
prompt = Prompts.update_prompt_by_command(f"/{command}", form_data)
prompt = Prompts.update_prompt_by_command(f"/{command}", form_data, db=db)
if prompt:
return prompt
else:
@ -147,8 +181,10 @@ async def update_prompt_by_command(
@router.delete("/command/{command}/delete", response_model=bool)
async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)):
prompt = Prompts.get_prompt_by_command(f"/{command}")
async def delete_prompt_by_command(
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -157,7 +193,7 @@ async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control)
and not has_access(user.id, "write", prompt.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -165,5 +201,5 @@ async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Prompts.delete_prompt_by_command(f"/{command}")
result = Prompts.delete_prompt_by_command(f"/{command}", db=db)
return result

View file

@ -39,6 +39,8 @@ from langchain_core.documents import Document
from open_webui.models.files import FileModel, FileUpdateForm, Files
from open_webui.models.knowledge import Knowledges
from open_webui.storage.provider import Storage
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
@ -91,6 +93,7 @@ from open_webui.utils.misc import (
sanitize_text_for_db,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.config import (
ENV,
@ -110,6 +113,7 @@ from open_webui.env import (
SENTENCE_TRANSFORMERS_MODEL_KWARGS,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION,
)
from open_webui.constants import ERROR_MESSAGES
@ -188,6 +192,7 @@ def get_rf(
raise Exception(ERROR_MESSAGES.DEFAULT(e))
else:
import sentence_transformers
import torch
try:
rf = sentence_transformers.CrossEncoder(
@ -196,6 +201,11 @@ def get_rf(
trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
activation_fn=(
torch.nn.Sigmoid()
if SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION
else None
),
)
except Exception as e:
log.error(f"CrossEncoder: {e}")
@ -494,7 +504,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"RAG_EXTERNAL_RERANKER_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
# Chunking settings
"TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER,
"ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER": request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
"CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
"CHUNK_MIN_SIZE_TARGET": request.app.state.config.CHUNK_MIN_SIZE_TARGET,
"CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP,
# File upload settings
"FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE,
@ -532,12 +544,14 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"SERPSTACK_HTTPS": request.app.state.config.SERPSTACK_HTTPS,
"SERPER_API_KEY": request.app.state.config.SERPER_API_KEY,
"SERPLY_API_KEY": request.app.state.config.SERPLY_API_KEY,
"DDGS_BACKEND": request.app.state.config.DDGS_BACKEND,
"TAVILY_API_KEY": request.app.state.config.TAVILY_API_KEY,
"SEARCHAPI_API_KEY": request.app.state.config.SEARCHAPI_API_KEY,
"SEARCHAPI_ENGINE": request.app.state.config.SEARCHAPI_ENGINE,
"SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY,
"SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE,
"JINA_API_KEY": request.app.state.config.JINA_API_KEY,
"JINA_API_BASE_URL": request.app.state.config.JINA_API_BASE_URL,
"BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"EXA_API_KEY": request.app.state.config.EXA_API_KEY,
@ -554,6 +568,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
"FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY,
"FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL,
"FIRECRAWL_TIMEOUT": request.app.state.config.FIRECRAWL_TIMEOUT,
"TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH,
"EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
"EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
@ -592,12 +607,14 @@ class WebConfig(BaseModel):
SERPSTACK_HTTPS: Optional[bool] = None
SERPER_API_KEY: Optional[str] = None
SERPLY_API_KEY: Optional[str] = None
DDGS_BACKEND: Optional[str] = None
TAVILY_API_KEY: Optional[str] = None
SEARCHAPI_API_KEY: Optional[str] = None
SEARCHAPI_ENGINE: Optional[str] = None
SERPAPI_API_KEY: Optional[str] = None
SERPAPI_ENGINE: Optional[str] = None
JINA_API_KEY: Optional[str] = None
JINA_API_BASE_URL: Optional[str] = None
BING_SEARCH_V7_ENDPOINT: Optional[str] = None
BING_SEARCH_V7_SUBSCRIPTION_KEY: Optional[str] = None
EXA_API_KEY: Optional[str] = None
@ -614,6 +631,7 @@ class WebConfig(BaseModel):
PLAYWRIGHT_TIMEOUT: Optional[int] = None
FIRECRAWL_API_KEY: Optional[str] = None
FIRECRAWL_API_BASE_URL: Optional[str] = None
FIRECRAWL_TIMEOUT: Optional[str] = None
TAVILY_EXTRACT_DEPTH: Optional[str] = None
EXTERNAL_WEB_SEARCH_URL: Optional[str] = None
EXTERNAL_WEB_SEARCH_API_KEY: Optional[str] = None
@ -683,7 +701,9 @@ class ConfigForm(BaseModel):
# Chunking settings
TEXT_SPLITTER: Optional[str] = None
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER: Optional[bool] = None
CHUNK_SIZE: Optional[int] = None
CHUNK_MIN_SIZE_TARGET: Optional[int] = None
CHUNK_OVERLAP: Optional[int] = None
# File upload settings
@ -991,6 +1011,11 @@ async def update_rag_config(
if form_data.CHUNK_SIZE is not None
else request.app.state.config.CHUNK_SIZE
)
request.app.state.config.CHUNK_MIN_SIZE_TARGET = (
form_data.CHUNK_MIN_SIZE_TARGET
if form_data.CHUNK_MIN_SIZE_TARGET is not None
else request.app.state.config.CHUNK_MIN_SIZE_TARGET
)
request.app.state.config.CHUNK_OVERLAP = (
form_data.CHUNK_OVERLAP
if form_data.CHUNK_OVERLAP is not None
@ -1075,12 +1100,14 @@ async def update_rag_config(
request.app.state.config.SERPSTACK_HTTPS = form_data.web.SERPSTACK_HTTPS
request.app.state.config.SERPER_API_KEY = form_data.web.SERPER_API_KEY
request.app.state.config.SERPLY_API_KEY = form_data.web.SERPLY_API_KEY
request.app.state.config.DDGS_BACKEND = form_data.web.DDGS_BACKEND
request.app.state.config.TAVILY_API_KEY = form_data.web.TAVILY_API_KEY
request.app.state.config.SEARCHAPI_API_KEY = form_data.web.SEARCHAPI_API_KEY
request.app.state.config.SEARCHAPI_ENGINE = form_data.web.SEARCHAPI_ENGINE
request.app.state.config.SERPAPI_API_KEY = form_data.web.SERPAPI_API_KEY
request.app.state.config.SERPAPI_ENGINE = form_data.web.SERPAPI_ENGINE
request.app.state.config.JINA_API_KEY = form_data.web.JINA_API_KEY
request.app.state.config.JINA_API_BASE_URL = form_data.web.JINA_API_BASE_URL
request.app.state.config.BING_SEARCH_V7_ENDPOINT = (
form_data.web.BING_SEARCH_V7_ENDPOINT
)
@ -1112,6 +1139,7 @@ async def update_rag_config(
request.app.state.config.FIRECRAWL_API_BASE_URL = (
form_data.web.FIRECRAWL_API_BASE_URL
)
request.app.state.config.FIRECRAWL_TIMEOUT = form_data.web.FIRECRAWL_TIMEOUT
request.app.state.config.EXTERNAL_WEB_SEARCH_URL = (
form_data.web.EXTERNAL_WEB_SEARCH_URL
)
@ -1188,6 +1216,8 @@ async def update_rag_config(
# Chunking settings
"TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER,
"CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
"CHUNK_MIN_SIZE_TARGET": request.app.state.config.CHUNK_MIN_SIZE_TARGET,
"ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER": request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
"CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP,
# File upload settings
"FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE,
@ -1231,6 +1261,7 @@ async def update_rag_config(
"SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY,
"SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE,
"JINA_API_KEY": request.app.state.config.JINA_API_KEY,
"JINA_API_BASE_URL": request.app.state.config.JINA_API_BASE_URL,
"BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"EXA_API_KEY": request.app.state.config.EXA_API_KEY,
@ -1247,6 +1278,7 @@ async def update_rag_config(
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
"FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY,
"FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL,
"FIRECRAWL_TIMEOUT": request.app.state.config.FIRECRAWL_TIMEOUT,
"TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH,
"EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
"EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
@ -1266,6 +1298,85 @@ async def update_rag_config(
####################################
def can_merge_chunks(a: Document, b: Document) -> bool:
if a.metadata.get("source") != b.metadata.get("source"):
return False
a_file_id = a.metadata.get("file_id")
b_file_id = b.metadata.get("file_id")
if a_file_id is not None and b_file_id is not None:
return a_file_id == b_file_id
return True
def merge_docs_to_target_size(
request: Request,
chunks: list[Document],
) -> list[Document]:
"""
Best-effort normalization of chunk sizes.
Attempts to grow small chunks up to a desired minimum size,
without exceeding the maximum size or crossing source/file
boundaries.
"""
min_chunk_size_target = request.app.state.config.CHUNK_MIN_SIZE_TARGET
max_chunk_size = request.app.state.config.CHUNK_SIZE
if min_chunk_size_target <= 0:
return chunks
measure_chunk_size = len
if request.app.state.config.TEXT_SPLITTER == "token":
encoding = tiktoken.get_encoding(
str(request.app.state.config.TIKTOKEN_ENCODING_NAME)
)
measure_chunk_size = lambda text: len(encoding.encode(text))
processed_chunks: list[Document] = []
current_chunk: Document | None = None
current_content: str = ""
for next_chunk in chunks:
if current_chunk is None:
current_chunk = next_chunk
current_content = next_chunk.page_content
continue # First chunk initialization
proposed_content = f"{current_content}\n\n{next_chunk.page_content}"
can_merge = (
can_merge_chunks(current_chunk, next_chunk)
and measure_chunk_size(current_content) < min_chunk_size_target
and measure_chunk_size(proposed_content) <= max_chunk_size
)
if can_merge:
current_content = proposed_content
else:
processed_chunks.append(
Document(
page_content=current_content,
metadata={**current_chunk.metadata},
)
)
current_chunk = next_chunk
current_content = next_chunk.page_content
if current_chunk is not None:
processed_chunks.append(
Document(
page_content=current_content,
metadata={**current_chunk.metadata},
)
)
return processed_chunks
def save_docs_to_vector_db(
request: Request,
docs,
@ -1303,13 +1414,46 @@ def save_docs_to_vector_db(
filter={"hash": metadata["hash"]},
)
if result is not None:
if result is not None and result.ids and len(result.ids) > 0:
existing_doc_ids = result.ids[0]
if existing_doc_ids:
log.info(f"Document with hash {metadata['hash']} already exists")
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
if split:
if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER:
log.info("Using markdown header text splitter")
# Define headers to split on - covering most common markdown header levels
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
("####", "Header 4"),
("#####", "Header 5"),
("######", "Header 6"),
],
strip_headers=False, # Keep headers in content for context
)
split_docs = []
for doc in docs:
split_docs.extend(
[
Document(
page_content=split_chunk.page_content,
metadata={**doc.metadata},
)
for split_chunk in markdown_splitter.split_text(
doc.page_content
)
]
)
docs = split_docs
if request.app.state.config.CHUNK_MIN_SIZE_TARGET > 0:
docs = merge_docs_to_target_size(request, docs)
if request.app.state.config.TEXT_SPLITTER in ["", "character"]:
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=request.app.state.config.CHUNK_SIZE,
@ -1330,52 +1474,6 @@ def save_docs_to_vector_db(
add_start_index=True,
)
docs = text_splitter.split_documents(docs)
elif request.app.state.config.TEXT_SPLITTER == "markdown_header":
log.info("Using markdown header text splitter")
# Define headers to split on - covering most common markdown header levels
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
("####", "Header 4"),
("#####", "Header 5"),
("######", "Header 6"),
]
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False, # Keep headers in content for context
)
md_split_docs = []
for doc in docs:
md_header_splits = markdown_splitter.split_text(doc.page_content)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=request.app.state.config.CHUNK_SIZE,
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
add_start_index=True,
)
md_header_splits = text_splitter.split_documents(md_header_splits)
# Convert back to Document objects, preserving original metadata
for split_chunk in md_header_splits:
headings_list = []
# Extract header values in order based on headers_to_split_on
for _, header_meta_key_name in headers_to_split_on:
if header_meta_key_name in split_chunk.metadata:
headings_list.append(
split_chunk.metadata[header_meta_key_name]
)
md_split_docs.append(
Document(
page_content=split_chunk.page_content,
metadata={**doc.metadata, "headings": headings_list},
)
)
docs = md_split_docs
else:
raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter"))
@ -1484,14 +1582,15 @@ def process_file(
request: Request,
form_data: ProcessFileForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""
Process a file and save its content to the vector database.
"""
if user.role == "admin":
file = Files.get_file_by_id(form_data.file_id)
file = Files.get_file_by_id(form_data.file_id, db=db)
else:
file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id)
file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id, db=db)
if file:
try:
@ -1633,12 +1732,13 @@ def process_file(
Files.update_file_data_by_id(
file.id,
{"content": text_content},
db=db,
)
hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash)
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
Files.update_file_data_by_id(file.id, {"status": "completed"})
Files.update_file_data_by_id(file.id, {"status": "completed"}, db=db)
Files.update_file_hash_by_id(file.id, hash, db=db)
return {
"status": True,
"collection_name": None,
@ -1667,12 +1767,15 @@ def process_file(
{
"collection_name": collection_name,
},
db=db,
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
db=db,
)
Files.update_file_hash_by_id(file.id, hash, db=db)
return {
"status": True,
@ -1690,7 +1793,10 @@ def process_file(
Files.update_file_data_by_id(
file.id,
{"status": "failed"},
db=db,
)
# Clear the hash so the file can be re-uploaded after fixing the issue
Files.update_file_hash_by_id(file.id, None, db=db)
if "No pandoc was found" in str(e):
raise HTTPException(
@ -1972,6 +2078,7 @@ def search_web(
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
backend=request.app.state.config.DDGS_BACKEND,
)
elif engine == "tavily":
if request.app.state.config.TAVILY_API_KEY:
@ -2020,6 +2127,7 @@ def search_web(
request.app.state.config.JINA_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.JINA_API_BASE_URL,
)
elif engine == "bing":
return search_bing(
@ -2106,6 +2214,19 @@ def search_web(
async def process_web_search(
request: Request, form_data: SearchForm, user=Depends(get_verified_user)
):
if not request.app.state.config.ENABLE_WEB_SEARCH:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != "admin" and not has_permission(
user.id, "features.web_search", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
urls = []
result_items = []
@ -2417,10 +2538,19 @@ class DeleteForm(BaseModel):
@router.post("/delete")
def delete_entries_from_collection(form_data: DeleteForm, user=Depends(get_admin_user)):
def delete_entries_from_collection(
form_data: DeleteForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name):
file = Files.get_file_by_id(form_data.file_id)
file = Files.get_file_by_id(form_data.file_id, db=db)
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
hash = file.hash
VECTOR_DB_CLIENT.delete(
@ -2436,9 +2566,9 @@ def delete_entries_from_collection(form_data: DeleteForm, user=Depends(get_admin
@router.post("/reset/db")
def reset_vector_db(user=Depends(get_admin_user)):
def reset_vector_db(user=Depends(get_admin_user), db: Session = Depends(get_session)):
VECTOR_DB_CLIENT.reset()
Knowledges.delete_all_knowledge()
Knowledges.delete_all_knowledge(db=db)
@router.post("/reset/uploads")
@ -2496,6 +2626,7 @@ async def process_files_batch(
request: Request,
form_data: BatchProcessFilesForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
) -> BatchProcessFilesResponse:
"""
Process a batch of files and save them to the vector database.
@ -2558,7 +2689,9 @@ async def process_files_batch(
# Update all files with collection name
for file_update, file_result in zip(file_updates, file_results):
Files.update_file_by_id(id=file_result.file_id, form_data=file_update)
Files.update_file_by_id(
id=file_result.file_id, form_data=file_update, db=db
)
file_result.status = "completed"
except Exception as e:

View file

@ -25,6 +25,10 @@ from open_webui.utils.auth import (
)
from open_webui.constants import ERROR_MESSAGES
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
log = logging.getLogger(__name__)
router = APIRouter()
@ -296,7 +300,7 @@ def get_scim_auth(
)
def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
"""Convert internal User model to SCIM User"""
# Parse display name into name components
name_parts = user.name.split(" ", 1) if user.name else ["", ""]
@ -304,7 +308,7 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
family_name = name_parts[1] if len(name_parts) > 1 else ""
# Get user's groups
user_groups = Groups.get_groups_by_member_id(user.id)
user_groups = Groups.get_groups_by_member_id(user.id, db=db)
groups = [
{
"value": group.id,
@ -345,13 +349,13 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
)
def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup:
def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup:
"""Convert internal Group model to SCIM Group"""
member_ids = Groups.get_group_user_ids_by_id(group.id)
member_ids = Groups.get_group_user_ids_by_id(group.id, db) or []
members = []
for user_id in member_ids:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
if user:
members.append(
SCIMGroupMember(
@ -483,6 +487,7 @@ async def get_users(
count: int = Query(20, ge=1, le=100),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""List SCIM Users"""
skip = startIndex - 1
@ -494,20 +499,20 @@ async def get_users(
# In production, you'd want a more robust filter parser
if "userName eq" in filter:
email = filter.split('"')[1]
user = Users.get_user_by_email(email)
user = Users.get_user_by_email(email, db=db)
users_list = [user] if user else []
total = 1 if user else 0
else:
response = Users.get_users(skip=skip, limit=limit)
response = Users.get_users(skip=skip, limit=limit, db=db)
users_list = response["users"]
total = response["total"]
else:
response = Users.get_users(skip=skip, limit=limit)
response = Users.get_users(skip=skip, limit=limit, db=db)
users_list = response["users"]
total = response["total"]
# Convert to SCIM format
scim_users = [user_to_scim(user, request) for user in users_list]
scim_users = [user_to_scim(user, request, db=db) for user in users_list]
return SCIMListResponse(
totalResults=total,
@ -522,15 +527,16 @@ async def get_user(
user_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Get SCIM User by ID"""
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
if not user:
return scim_error(
status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found"
)
return user_to_scim(user, request)
return user_to_scim(user, request, db=db)
@router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED)
@ -538,10 +544,11 @@ async def create_user(
request: Request,
user_data: SCIMUserCreateRequest,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Create SCIM User"""
# Check if user already exists
existing_user = Users.get_user_by_email(user_data.userName)
existing_user = Users.get_user_by_email(user_data.userName, db=db)
if existing_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@ -572,6 +579,7 @@ async def create_user(
email=email,
profile_image_url=profile_image,
role="user" if user_data.active else "pending",
db=db,
)
if not new_user:
@ -580,7 +588,7 @@ async def create_user(
detail="Failed to create user",
)
return user_to_scim(new_user, request)
return user_to_scim(new_user, request, db=db)
@router.put("/Users/{user_id}", response_model=SCIMUser)
@ -589,9 +597,10 @@ async def update_user(
request: Request,
user_data: SCIMUserUpdateRequest,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Update SCIM User (full update)"""
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -624,14 +633,14 @@ async def update_user(
update_data["profile_image_url"] = user_data.photos[0].value
# Update user
updated_user = Users.update_user_by_id(user_id, update_data)
updated_user = Users.update_user_by_id(user_id, update_data, db=db)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user",
)
return user_to_scim(updated_user, request)
return user_to_scim(updated_user, request, db=db)
@router.patch("/Users/{user_id}", response_model=SCIMUser)
@ -640,9 +649,10 @@ async def patch_user(
request: Request,
patch_data: SCIMPatchRequest,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Update SCIM User (partial update)"""
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -670,7 +680,7 @@ async def patch_user(
# Update user
if update_data:
updated_user = Users.update_user_by_id(user_id, update_data)
updated_user = Users.update_user_by_id(user_id, update_data, db=db)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -679,7 +689,7 @@ async def patch_user(
else:
updated_user = user
return user_to_scim(updated_user, request)
return user_to_scim(updated_user, request, db=db)
@router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@ -687,16 +697,17 @@ async def delete_user(
user_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Delete SCIM User"""
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found",
)
success = Users.delete_user_by_id(user_id)
success = Users.delete_user_by_id(user_id, db=db)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -714,10 +725,11 @@ async def get_groups(
count: int = Query(20, ge=1, le=100),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""List SCIM Groups"""
# Get all groups
groups_list = Groups.get_all_groups()
groups_list = Groups.get_all_groups(db=db)
# Apply pagination
total = len(groups_list)
@ -726,7 +738,7 @@ async def get_groups(
paginated_groups = groups_list[start:end]
# Convert to SCIM format
scim_groups = [group_to_scim(group, request) for group in paginated_groups]
scim_groups = [group_to_scim(group, request, db=db) for group in paginated_groups]
return SCIMListResponse(
totalResults=total,
@ -741,16 +753,17 @@ async def get_group(
group_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Get SCIM Group by ID"""
group = Groups.get_group_by_id(group_id)
group = Groups.get_group_by_id(group_id, db=db)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
return group_to_scim(group, request)
return group_to_scim(group, request, db=db)
@router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
@ -758,6 +771,7 @@ async def create_group(
request: Request,
group_data: SCIMGroupCreateRequest,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Create SCIM Group"""
# Extract member IDs
@ -775,14 +789,14 @@ async def create_group(
)
# Need to get the creating user's ID - we'll use the first admin
admin_user = Users.get_super_admin_user()
admin_user = Users.get_super_admin_user(db=db)
if not admin_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No admin user found",
)
new_group = Groups.insert_new_group(admin_user.id, form)
new_group = Groups.insert_new_group(admin_user.id, form, db=db)
if not new_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -798,12 +812,12 @@ async def create_group(
description=new_group.description,
)
Groups.update_group_by_id(new_group.id, update_form)
Groups.set_group_user_ids_by_id(new_group.id, member_ids)
Groups.update_group_by_id(new_group.id, update_form, db=db)
Groups.set_group_user_ids_by_id(new_group.id, member_ids, db=db)
new_group = Groups.get_group_by_id(new_group.id)
new_group = Groups.get_group_by_id(new_group.id, db=db)
return group_to_scim(new_group, request)
return group_to_scim(new_group, request, db=db)
@router.put("/Groups/{group_id}", response_model=SCIMGroup)
@ -812,9 +826,10 @@ async def update_group(
request: Request,
group_data: SCIMGroupUpdateRequest,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Update SCIM Group (full update)"""
group = Groups.get_group_by_id(group_id)
group = Groups.get_group_by_id(group_id, db=db)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -832,17 +847,17 @@ async def update_group(
# Handle members if provided
if group_data.members is not None:
member_ids = [member.value for member in group_data.members]
Groups.set_group_user_ids_by_id(group_id, member_ids)
Groups.set_group_user_ids_by_id(group_id, member_ids, db=db)
# Update group
updated_group = Groups.update_group_by_id(group_id, update_form)
updated_group = Groups.update_group_by_id(group_id, update_form, db=db)
if not updated_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update group",
)
return group_to_scim(updated_group, request)
return group_to_scim(updated_group, request, db=db)
@router.patch("/Groups/{group_id}", response_model=SCIMGroup)
@ -851,9 +866,10 @@ async def patch_group(
request: Request,
patch_data: SCIMPatchRequest,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Update SCIM Group (partial update)"""
group = Groups.get_group_by_id(group_id)
group = Groups.get_group_by_id(group_id, db=db)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -878,7 +894,7 @@ async def patch_group(
elif path == "members":
# Replace all members
Groups.set_group_user_ids_by_id(
group_id, [member["value"] for member in value]
group_id, [member["value"] for member in value], db=db
)
elif op == "add":
@ -887,22 +903,24 @@ async def patch_group(
if isinstance(value, list):
for member in value:
if isinstance(member, dict) and "value" in member:
Groups.add_users_to_group(group_id, [member["value"]])
Groups.add_users_to_group(
group_id, [member["value"]], db=db
)
elif op == "remove":
if path and path.startswith("members[value eq"):
# Remove specific member
member_id = path.split('"')[1]
Groups.remove_users_from_group(group_id, [member_id])
Groups.remove_users_from_group(group_id, [member_id], db=db)
# Update group
updated_group = Groups.update_group_by_id(group_id, update_form)
updated_group = Groups.update_group_by_id(group_id, update_form, db=db)
if not updated_group:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update group",
)
return group_to_scim(updated_group, request)
return group_to_scim(updated_group, request, db=db)
@router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
@ -910,16 +928,17 @@ async def delete_group(
group_id: str,
request: Request,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""Delete SCIM Group"""
group = Groups.get_group_by_id(group_id)
group = Groups.get_group_by_id(group_id, db=db)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found",
)
success = Groups.delete_group_by_id(group_id)
success = Groups.delete_group_by_id(group_id, db=db)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View file

@ -4,9 +4,12 @@ from typing import Optional
import time
import re
import aiohttp
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.models.groups import Groups
from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.oauth_sessions import OAuthSessions
@ -15,6 +18,7 @@ from open_webui.models.tools import (
ToolModel,
ToolResponse,
ToolUserResponse,
ToolAccessResponse,
Tools,
)
from open_webui.utils.plugin import (
@ -51,11 +55,15 @@ def get_tool_module(request, tool_id, load_from_db=True):
@router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)):
async def get_tools(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = []
# Local Tools
for tool in Tools.get_tools():
for tool in Tools.get_tools(db=db):
tool_module = get_tool_module(request, tool.id)
tools.append(
ToolUserResponse(
@ -140,12 +148,14 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
# Admin can see all tools
return tools
else:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
tools = [
tool
for tool in tools
if tool.user_id == user.id
or has_access(user.id, "read", tool.access_control, user_group_ids)
or has_access(user.id, "read", tool.access_control, user_group_ids, db=db)
]
return tools
@ -155,13 +165,26 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
############################
@router.get("/list", response_model=list[ToolUserResponse])
async def get_tool_list(user=Depends(get_verified_user)):
@router.get("/list", response_model=list[ToolAccessResponse])
async def get_tool_list(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
tools = Tools.get_tools()
tools = Tools.get_tools(db=db)
else:
tools = Tools.get_tools_by_user_id(user.id, "write")
return tools
tools = Tools.get_tools_by_user_id(user.id, "read", db=db)
return [
ToolAccessResponse(
**tool.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tool.user_id
or has_access(user.id, "write", tool.access_control, db=db)
),
)
for tool in tools
]
############################
@ -218,7 +241,9 @@ async def load_tool_from_url(
)
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.get(
url, headers={"Content-Type": "application/json"}
) as resp:
@ -245,9 +270,16 @@ async def load_tool_from_url(
@router.get("/export", response_model=list[ToolModel])
async def export_tools(request: Request, user=Depends(get_verified_user)):
async def export_tools(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission(
user.id, "workspace.tools_export", request.app.state.config.USER_PERMISSIONS
user.id,
"workspace.tools_export",
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -255,9 +287,9 @@ async def export_tools(request: Request, user=Depends(get_verified_user)):
)
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return Tools.get_tools()
return Tools.get_tools(db=db)
else:
return Tools.get_tools_by_user_id(user.id, "read")
return Tools.get_tools_by_user_id(user.id, "read", db=db)
############################
@ -270,13 +302,17 @@ async def create_new_tools(
request: Request,
form_data: ToolForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not (
has_permission(
user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS
user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS, db=db
)
or has_permission(
user.id, "workspace.tools_import", request.app.state.config.USER_PERMISSIONS
user.id,
"workspace.tools_import",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
raise HTTPException(
@ -292,7 +328,7 @@ async def create_new_tools(
form_data.id = form_data.id.lower()
tools = Tools.get_tool_by_id(form_data.id)
tools = Tools.get_tool_by_id(form_data.id, db=db)
if tools is None:
try:
form_data.content = replace_imports(form_data.content)
@ -305,7 +341,7 @@ async def create_new_tools(
TOOLS[form_data.id] = tool_module
specs = get_tool_specs(TOOLS[form_data.id])
tools = Tools.insert_new_tool(user.id, form_data, specs)
tools = Tools.insert_new_tool(user.id, form_data, specs, db=db)
tool_cache_dir = CACHE_DIR / "tools" / form_data.id
tool_cache_dir.mkdir(parents=True, exist_ok=True)
@ -335,20 +371,34 @@ async def create_new_tools(
############################
@router.get("/id/{id}", response_model=Optional[ToolModel])
async def get_tools_by_id(id: str, user=Depends(get_verified_user)):
tools = Tools.get_tool_by_id(id)
@router.get("/id/{id}", response_model=Optional[ToolAccessResponse])
async def get_tools_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
tools = Tools.get_tool_by_id(id, db=db)
if tools:
if (
user.role == "admin"
or tools.user_id == user.id
or has_access(user.id, "read", tools.access_control)
or has_access(user.id, "read", tools.access_control, db=db)
):
return tools
return ToolAccessResponse(
**tools.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tools.user_id
or has_access(user.id, "write", tools.access_control, db=db)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
@ -364,8 +414,9 @@ async def update_tools_by_id(
id: str,
form_data: ToolForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id)
tools = Tools.get_tool_by_id(id, db=db)
if not tools:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -375,7 +426,7 @@ async def update_tools_by_id(
# Is the user the original creator, in a group with write access, or an admin
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and not has_access(user.id, "write", tools.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -399,7 +450,7 @@ async def update_tools_by_id(
}
log.debug(updated)
tools = Tools.update_tool_by_id(id, updated)
tools = Tools.update_tool_by_id(id, updated, db=db)
if tools:
return tools
@ -423,9 +474,12 @@ async def update_tools_by_id(
@router.delete("/id/{id}/delete", response_model=bool)
async def delete_tools_by_id(
request: Request, id: str, user=Depends(get_verified_user)
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id)
tools = Tools.get_tool_by_id(id, db=db)
if not tools:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -434,7 +488,7 @@ async def delete_tools_by_id(
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and not has_access(user.id, "write", tools.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -442,7 +496,7 @@ async def delete_tools_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
result = Tools.delete_tool_by_id(id)
result = Tools.delete_tool_by_id(id, db=db)
if result:
TOOLS = request.app.state.TOOLS
if id in TOOLS:
@ -457,11 +511,13 @@ async def delete_tools_by_id(
@router.get("/id/{id}/valves", response_model=Optional[dict])
async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user)):
tools = Tools.get_tool_by_id(id)
async def get_tools_valves_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
tools = Tools.get_tool_by_id(id, db=db)
if tools:
try:
valves = Tools.get_tool_valves_by_id(id)
valves = Tools.get_tool_valves_by_id(id, db=db)
return valves
except Exception as e:
raise HTTPException(
@ -482,9 +538,12 @@ async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/id/{id}/valves/spec", response_model=Optional[dict])
async def get_tools_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user)
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id)
tools = Tools.get_tool_by_id(id, db=db)
if tools:
if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id]
@ -510,9 +569,13 @@ async def get_tools_valves_spec_by_id(
@router.post("/id/{id}/valves/update", response_model=Optional[dict])
async def update_tools_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user)
request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id)
tools = Tools.get_tool_by_id(id, db=db)
if not tools:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -521,7 +584,7 @@ async def update_tools_valves_by_id(
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and not has_access(user.id, "write", tools.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
@ -546,7 +609,7 @@ async def update_tools_valves_by_id(
form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data)
valves_dict = valves.model_dump(exclude_unset=True)
Tools.update_tool_valves_by_id(id, valves_dict)
Tools.update_tool_valves_by_id(id, valves_dict, db=db)
return valves_dict
except Exception as e:
log.exception(f"Failed to update tool valves by id {id}: {e}")
@ -562,11 +625,13 @@ async def update_tools_valves_by_id(
@router.get("/id/{id}/valves/user", response_model=Optional[dict])
async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user)):
tools = Tools.get_tool_by_id(id)
async def get_tools_user_valves_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
tools = Tools.get_tool_by_id(id, db=db)
if tools:
try:
user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id)
user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db)
return user_valves
except Exception as e:
raise HTTPException(
@ -582,9 +647,12 @@ async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/id/{id}/valves/user/spec", response_model=Optional[dict])
async def get_tools_user_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user)
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id)
tools = Tools.get_tool_by_id(id, db=db)
if tools:
if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id]
@ -605,9 +673,13 @@ async def get_tools_user_valves_spec_by_id(
@router.post("/id/{id}/valves/user/update", response_model=Optional[dict])
async def update_tools_user_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user)
request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id)
tools = Tools.get_tool_by_id(id, db=db)
if tools:
if id in request.app.state.TOOLS:
@ -624,7 +696,7 @@ async def update_tools_user_valves_by_id(
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_dict
id, user.id, user_valves_dict, db=db
)
return user_valves_dict
except Exception as e:

View file

@ -1,5 +1,6 @@
import logging
from typing import Optional
from sqlalchemy.orm import Session
import base64
import io
@ -29,6 +30,7 @@ from open_webui.models.users import (
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import STATIC_DIR
from open_webui.internal.db import get_session
from open_webui.utils.auth import (
@ -60,6 +62,7 @@ async def get_users(
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
@ -74,19 +77,23 @@ async def get_users(
if direction:
filter["direction"] = direction
result = Users.get_users(filter=filter, skip=skip, limit=limit)
filter["direction"] = direction
result = Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
users = result["users"]
total = result["total"]
# Fetch groups for all users in a single query to avoid N+1
user_ids = [user.id for user in users]
user_groups = Groups.get_groups_by_member_ids(user_ids, db=db)
return {
"users": [
UserGroupIdsModel(
**{
**user.model_dump(),
"group_ids": [
group.id for group in Groups.get_groups_by_member_id(user.id)
],
"group_ids": [group.id for group in user_groups.get(user.id, [])],
}
)
for user in users
@ -98,8 +105,9 @@ async def get_users(
@router.get("/all", response_model=UserInfoListResponse)
async def get_all_users(
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
return Users.get_users()
return Users.get_users(db=db)
@router.get("/search", response_model=UserInfoListResponse)
@ -109,16 +117,13 @@ async def search_users(
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
filter = {}
if query:
filter["query"] = query
@ -127,7 +132,7 @@ async def search_users(
if direction:
filter["direction"] = direction
return Users.get_users(filter=filter, skip=skip, limit=limit)
return Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
############################
@ -136,8 +141,10 @@ async def search_users(
@router.get("/groups")
async def get_user_groups(user=Depends(get_verified_user)):
return Groups.get_groups_by_member_id(user.id)
async def get_user_groups(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return Groups.get_groups_by_member_id(user.id, db=db)
############################
@ -146,9 +153,13 @@ async def get_user_groups(user=Depends(get_verified_user)):
@router.get("/permissions")
async def get_user_permissisions(request: Request, user=Depends(get_verified_user)):
async def get_user_permissisions(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return user_permissions
@ -215,6 +226,11 @@ class FeaturesPermissions(BaseModel):
web_search: bool = True
image_generation: bool = True
code_interpreter: bool = True
memories: bool = True
class SettingsPermissions(BaseModel):
interface: bool = True
class UserPermissions(BaseModel):
@ -222,6 +238,7 @@ class UserPermissions(BaseModel):
sharing: SharingPermissions
chat: ChatPermissions
features: FeaturesPermissions
settings: SettingsPermissions
@router.get("/default/permissions", response_model=UserPermissions)
@ -239,6 +256,9 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_
"features": FeaturesPermissions(
**request.app.state.config.USER_PERMISSIONS.get("features", {})
),
"settings": SettingsPermissions(
**request.app.state.config.USER_PERMISSIONS.get("settings", {})
),
}
@ -256,8 +276,10 @@ async def update_default_user_permissions(
@router.get("/user/settings", response_model=Optional[UserSettings])
async def get_user_settings_by_session_user(user=Depends(get_verified_user)):
user = Users.get_user_by_id(user.id)
async def get_user_settings_by_session_user(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user.id, db=db)
if user:
return user.settings
else:
@ -274,12 +296,17 @@ async def get_user_settings_by_session_user(user=Depends(get_verified_user)):
@router.post("/user/settings/update", response_model=UserSettings)
async def update_user_settings_by_session_user(
request: Request, form_data: UserSettings, user=Depends(get_verified_user)
request: Request,
form_data: UserSettings,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
updated_user_settings = form_data.model_dump()
ui_settings = updated_user_settings.get("ui")
if (
user.role != "admin"
and "toolServers" in updated_user_settings.get("ui").keys()
and ui_settings is not None
and "toolServers" in ui_settings.keys()
and not has_permission(
user.id,
"features.direct_tool_servers",
@ -289,7 +316,7 @@ async def update_user_settings_by_session_user(
# If the user is not an admin and does not have permission to use tool servers, remove the key
updated_user_settings["ui"].pop("toolServers", None)
user = Users.update_user_settings_by_id(user.id, updated_user_settings)
user = Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
if user:
return user.settings
else:
@ -305,8 +332,17 @@ async def update_user_settings_by_session_user(
@router.get("/user/status")
async def get_user_status_by_session_user(user=Depends(get_verified_user)):
user = Users.get_user_by_id(user.id)
async def get_user_status_by_session_user(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_USER_STATUS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
user = Users.get_user_by_id(user.id, db=db)
if user:
return user
else:
@ -323,11 +359,19 @@ async def get_user_status_by_session_user(user=Depends(get_verified_user)):
@router.post("/user/status/update")
async def update_user_status_by_session_user(
form_data: UserStatus, user=Depends(get_verified_user)
request: Request,
form_data: UserStatus,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
user = Users.get_user_by_id(user.id)
if not request.app.state.config.ENABLE_USER_STATUS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
user = Users.get_user_by_id(user.id, db=db)
if user:
user = Users.update_user_status_by_id(user.id, form_data)
user = Users.update_user_status_by_id(user.id, form_data, db=db)
return user
else:
raise HTTPException(
@ -342,8 +386,10 @@ async def update_user_status_by_session_user(
@router.get("/user/info", response_model=Optional[dict])
async def get_user_info_by_session_user(user=Depends(get_verified_user)):
user = Users.get_user_by_id(user.id)
async def get_user_info_by_session_user(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user.id, db=db)
if user:
return user.info
else:
@ -360,14 +406,16 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user)):
@router.post("/user/info/update", response_model=Optional[dict])
async def update_user_info_by_session_user(
form_data: dict, user=Depends(get_verified_user)
form_data: dict, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user.id)
user = Users.get_user_by_id(user.id, db=db)
if user:
if user.info is None:
user.info = {}
user = Users.update_user_by_id(user.id, {"info": {**user.info, **form_data}})
user = Users.update_user_by_id(
user.id, {"info": {**user.info, **form_data}}, db=db
)
if user:
return user.info
else:
@ -397,7 +445,9 @@ class UserActiveResponse(UserStatus):
@router.get("/{user_id}", response_model=UserActiveResponse)
async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
async def get_user_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
# Check if user_id is a shared chat
# If it is, get the user_id from the chat
if user_id.startswith("shared-"):
@ -411,14 +461,14 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
if user:
groups = Groups.get_groups_by_member_id(user_id)
groups = Groups.get_groups_by_member_id(user_id, db=db)
return UserActiveResponse(
**{
**user.model_dump(),
"groups": [{"id": group.id, "name": group.name} for group in groups],
"is_active": Users.is_user_active(user_id),
"is_active": Users.is_user_active(user_id, db=db),
}
)
else:
@ -429,8 +479,10 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
@router.get("/{user_id}/oauth/sessions")
async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)):
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
async def get_user_oauth_sessions_by_id(
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
sessions = OAuthSessions.get_sessions_by_user_id(user_id, db=db)
if sessions and len(sessions) > 0:
return sessions
else:
@ -446,8 +498,10 @@ async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_use
@router.get("/{user_id}/profile/image")
async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)):
user = Users.get_user_by_id(user_id)
async def get_user_profile_image_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user_id, db=db)
if user:
if user.profile_image_url:
# check if it's url or base64
@ -461,11 +515,12 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u
header, base64_data = user.profile_image_url.split(",", 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
media_type = header.split(";")[0].lstrip("data:")
return StreamingResponse(
image_buffer,
media_type="image/png",
headers={"Content-Disposition": "inline; filename=image.png"},
media_type=media_type,
headers={"Content-Disposition": "inline"},
)
except Exception as e:
pass
@ -483,9 +538,11 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u
@router.get("/{user_id}/active", response_model=dict)
async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)):
async def get_user_active_status_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return {
"active": Users.is_user_active(user_id),
"active": Users.is_user_active(user_id, db=db),
}
@ -499,10 +556,11 @@ async def update_user_by_id(
user_id: str,
form_data: UserUpdateForm,
session_user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
# Prevent modification of the primary admin user by other admins
try:
first_user = Users.get_first_user()
first_user = Users.get_first_user(db=db)
if first_user:
if user_id == first_user.id:
if session_user.id != user_id:
@ -526,11 +584,11 @@ async def update_user_by_id(
detail="Could not verify primary admin status.",
)
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
if user:
if form_data.email.lower() != user.email:
email_user = Users.get_user_by_email(form_data.email.lower())
email_user = Users.get_user_by_email(form_data.email.lower(), db=db)
if email_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -544,9 +602,9 @@ async def update_user_by_id(
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password)
Auths.update_user_password_by_id(user_id, hashed)
Auths.update_user_password_by_id(user_id, hashed, db=db)
Auths.update_email_by_id(user_id, form_data.email.lower())
Auths.update_email_by_id(user_id, form_data.email.lower(), db=db)
updated_user = Users.update_user_by_id(
user_id,
{
@ -555,6 +613,7 @@ async def update_user_by_id(
"email": form_data.email.lower(),
"profile_image_url": form_data.profile_image_url,
},
db=db,
)
if updated_user:
@ -577,10 +636,12 @@ async def update_user_by_id(
@router.delete("/{user_id}", response_model=bool)
async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
async def delete_user_by_id(
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
# Prevent deletion of the primary admin user
try:
first_user = Users.get_first_user()
first_user = Users.get_first_user(db=db)
if first_user and user_id == first_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -594,7 +655,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
)
if user.id != user_id:
result = Auths.delete_auth_by_id(user_id)
result = Auths.delete_auth_by_id(user_id, db=db)
if result:
return True
@ -617,5 +678,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
@router.get("/{user_id}/groups")
async def get_user_groups_by_id(user_id: str, user=Depends(get_admin_user)):
return Groups.get_groups_by_member_id(user_id)
async def get_user_groups_by_id(
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Groups.get_groups_by_member_id(user_id, db=db)

View file

@ -246,7 +246,13 @@ def get_user_ids_from_room(room):
active_session_ids = get_session_ids_from_room(room)
active_user_ids = list(
set([SESSION_POOL.get(session_id)["id"] for session_id in active_session_ids])
set(
[
SESSION_POOL.get(session_id)["id"]
for session_id in active_session_ids
if SESSION_POOL.get(session_id) is not None
]
)
)
return active_user_ids

View file

@ -1,236 +0,0 @@
import uuid
from test.util.abstract_integration_test import AbstractPostgresTest
from test.util.mock_user import mock_webui_user
class TestChats(AbstractPostgresTest):
BASE_PATH = "/api/v1/chats"
def setup_class(cls):
super().setup_class()
def setup_method(self):
super().setup_method()
from open_webui.models.chats import ChatForm, Chats
self.chats = Chats
self.chats.insert_new_chat(
"2",
ChatForm(
**{
"chat": {
"name": "chat1",
"description": "chat1 description",
"tags": ["tag1", "tag2"],
"history": {"currentId": "1", "messages": []},
}
}
),
)
def test_get_session_user_chat_list(self):
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/"))
assert response.status_code == 200
first_chat = response.json()[0]
assert first_chat["id"] is not None
assert first_chat["title"] == "New Chat"
assert first_chat["created_at"] is not None
assert first_chat["updated_at"] is not None
def test_delete_all_user_chats(self):
with mock_webui_user(id="2"):
response = self.fast_api_client.delete(self.create_url("/"))
assert response.status_code == 200
assert len(self.chats.get_chats()) == 0
def test_get_user_chat_list_by_user_id(self):
with mock_webui_user(id="3"):
response = self.fast_api_client.get(self.create_url("/list/user/2"))
assert response.status_code == 200
first_chat = response.json()[0]
assert first_chat["id"] is not None
assert first_chat["title"] == "New Chat"
assert first_chat["created_at"] is not None
assert first_chat["updated_at"] is not None
def test_create_new_chat(self):
with mock_webui_user(id="2"):
response = self.fast_api_client.post(
self.create_url("/new"),
json={
"chat": {
"name": "chat2",
"description": "chat2 description",
"tags": ["tag1", "tag2"],
}
},
)
assert response.status_code == 200
data = response.json()
assert data["archived"] is False
assert data["chat"] == {
"name": "chat2",
"description": "chat2 description",
"tags": ["tag1", "tag2"],
}
assert data["user_id"] == "2"
assert data["id"] is not None
assert data["share_id"] is None
assert data["title"] == "New Chat"
assert data["updated_at"] is not None
assert data["created_at"] is not None
assert len(self.chats.get_chats()) == 2
def test_get_user_chats(self):
self.test_get_session_user_chat_list()
def test_get_user_archived_chats(self):
self.chats.archive_all_chats_by_user_id("2")
from open_webui.internal.db import Session
Session.commit()
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/all/archived"))
assert response.status_code == 200
first_chat = response.json()[0]
assert first_chat["id"] is not None
assert first_chat["title"] == "New Chat"
assert first_chat["created_at"] is not None
assert first_chat["updated_at"] is not None
def test_get_all_user_chats_in_db(self):
with mock_webui_user(id="4"):
response = self.fast_api_client.get(self.create_url("/all/db"))
assert response.status_code == 200
assert len(response.json()) == 1
def test_get_archived_session_user_chat_list(self):
self.test_get_user_archived_chats()
def test_archive_all_chats(self):
with mock_webui_user(id="2"):
response = self.fast_api_client.post(self.create_url("/archive/all"))
assert response.status_code == 200
assert len(self.chats.get_archived_chats_by_user_id("2")) == 1
def test_get_shared_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
self.chats.update_chat_share_id_by_id(chat_id, chat_id)
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url(f"/share/{chat_id}"))
assert response.status_code == 200
data = response.json()
assert data["id"] == chat_id
assert data["chat"] == {
"name": "chat1",
"description": "chat1 description",
"tags": ["tag1", "tag2"],
"history": {"currentId": "1", "messages": []},
}
assert data["id"] == chat_id
assert data["share_id"] == chat_id
assert data["title"] == "New Chat"
def test_get_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url(f"/{chat_id}"))
assert response.status_code == 200
data = response.json()
assert data["id"] == chat_id
assert data["chat"] == {
"name": "chat1",
"description": "chat1 description",
"tags": ["tag1", "tag2"],
"history": {"currentId": "1", "messages": []},
}
assert data["share_id"] is None
assert data["title"] == "New Chat"
assert data["user_id"] == "2"
def test_update_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.post(
self.create_url(f"/{chat_id}"),
json={
"chat": {
"name": "chat2",
"description": "chat2 description",
"tags": ["tag2", "tag4"],
"title": "Just another title",
}
},
)
assert response.status_code == 200
data = response.json()
assert data["id"] == chat_id
assert data["chat"] == {
"name": "chat2",
"title": "Just another title",
"description": "chat2 description",
"tags": ["tag2", "tag4"],
"history": {"currentId": "1", "messages": []},
}
assert data["share_id"] is None
assert data["title"] == "Just another title"
assert data["user_id"] == "2"
def test_delete_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.delete(self.create_url(f"/{chat_id}"))
assert response.status_code == 200
assert response.json() is True
def test_clone_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url(f"/{chat_id}/clone"))
assert response.status_code == 200
data = response.json()
assert data["id"] != chat_id
assert data["chat"] == {
"branchPointMessageId": "1",
"description": "chat1 description",
"history": {"currentId": "1", "messages": []},
"name": "chat1",
"originalChatId": chat_id,
"tags": ["tag1", "tag2"],
"title": "Clone of New Chat",
}
assert data["share_id"] is None
assert data["title"] == "Clone of New Chat"
assert data["user_id"] == "2"
def test_archive_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url(f"/{chat_id}/archive"))
assert response.status_code == 200
chat = self.chats.get_chat_by_id(chat_id)
assert chat.archived is True
def test_share_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.post(self.create_url(f"/{chat_id}/share"))
assert response.status_code == 200
chat = self.chats.get_chat_by_id(chat_id)
assert chat.share_id is not None
def test_delete_shared_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
share_id = str(uuid.uuid4())
self.chats.update_chat_share_id_by_id(chat_id, share_id)
with mock_webui_user(id="2"):
response = self.fast_api_client.delete(self.create_url(f"/{chat_id}/share"))
assert response.status_code
chat = self.chats.get_chat_by_id(chat_id)
assert chat.share_id is None

View file

@ -1,161 +0,0 @@
import logging
import os
import time
import docker
import pytest
from docker import DockerClient
from pytest_docker.plugin import get_docker_ip
from fastapi.testclient import TestClient
from sqlalchemy import text, create_engine
log = logging.getLogger(__name__)
def get_fast_api_client():
from main import app
with TestClient(app) as c:
return c
class AbstractIntegrationTest:
BASE_PATH = None
def create_url(self, path="", query_params=None):
if self.BASE_PATH is None:
raise Exception("BASE_PATH is not set")
parts = self.BASE_PATH.split("/")
parts = [part.strip() for part in parts if part.strip() != ""]
path_parts = path.split("/")
path_parts = [part.strip() for part in path_parts if part.strip() != ""]
query_parts = ""
if query_params:
query_parts = "&".join(
[f"{key}={value}" for key, value in query_params.items()]
)
query_parts = f"?{query_parts}"
return "/".join(parts + path_parts) + query_parts
@classmethod
def setup_class(cls):
pass
def setup_method(self):
pass
@classmethod
def teardown_class(cls):
pass
def teardown_method(self):
pass
class AbstractPostgresTest(AbstractIntegrationTest):
DOCKER_CONTAINER_NAME = "postgres-test-container-will-get-deleted"
docker_client: DockerClient
@classmethod
def _create_db_url(cls, env_vars_postgres: dict) -> str:
host = get_docker_ip()
user = env_vars_postgres["POSTGRES_USER"]
pw = env_vars_postgres["POSTGRES_PASSWORD"]
port = 8081
db = env_vars_postgres["POSTGRES_DB"]
return f"postgresql://{user}:{pw}@{host}:{port}/{db}"
@classmethod
def setup_class(cls):
super().setup_class()
try:
env_vars_postgres = {
"POSTGRES_USER": "user",
"POSTGRES_PASSWORD": "example",
"POSTGRES_DB": "openwebui",
}
cls.docker_client = docker.from_env()
cls.docker_client.containers.run(
"postgres:16.2",
detach=True,
environment=env_vars_postgres,
name=cls.DOCKER_CONTAINER_NAME,
ports={5432: ("0.0.0.0", 8081)},
command="postgres -c log_statement=all",
)
time.sleep(0.5)
database_url = cls._create_db_url(env_vars_postgres)
os.environ["DATABASE_URL"] = database_url
retries = 10
db = None
while retries > 0:
try:
from open_webui.config import OPEN_WEBUI_DIR
db = create_engine(database_url, pool_pre_ping=True)
db = db.connect()
log.info("postgres is ready!")
break
except Exception as e:
log.warning(e)
time.sleep(3)
retries -= 1
if db:
# import must be after setting env!
cls.fast_api_client = get_fast_api_client()
db.close()
else:
raise Exception("Could not connect to Postgres")
except Exception as ex:
log.error(ex)
cls.teardown_class()
pytest.fail(f"Could not setup test environment: {ex}")
def _check_db_connection(self):
from open_webui.internal.db import Session
retries = 10
while retries > 0:
try:
Session.execute(text("SELECT 1"))
Session.commit()
break
except Exception as e:
Session.rollback()
log.warning(e)
time.sleep(3)
retries -= 1
def setup_method(self):
super().setup_method()
self._check_db_connection()
@classmethod
def teardown_class(cls) -> None:
super().teardown_class()
cls.docker_client.containers.get(cls.DOCKER_CONTAINER_NAME).remove(force=True)
def teardown_method(self):
from open_webui.internal.db import Session
# rollback everything not yet committed
Session.commit()
# truncate all tables
tables = [
"auth",
"chat",
"chatidtag",
"document",
"memory",
"model",
"prompt",
"tag",
'"user"',
]
for table in tables:
Session.execute(text(f"TRUNCATE TABLE {table}"))
Session.commit()

View file

@ -1,45 +0,0 @@
from contextlib import contextmanager
from fastapi import FastAPI
@contextmanager
def mock_webui_user(**kwargs):
from open_webui.routers.webui import app
with mock_user(app, **kwargs):
yield
@contextmanager
def mock_user(app: FastAPI, **kwargs):
from open_webui.utils.auth import (
get_current_user,
get_verified_user,
get_admin_user,
get_current_user_by_api_key,
)
from open_webui.models.users import User
def create_user():
user_parameters = {
"id": "1",
"name": "John Doe",
"email": "john.doe@openwebui.com",
"role": "user",
"profile_image_url": "/user.png",
"last_active_at": 1627351200,
"updated_at": 1627351200,
"created_at": 162735120,
**kwargs,
}
return User(**user_parameters)
app.dependency_overrides = {
get_current_user: create_user,
get_verified_user: create_user,
get_admin_user: create_user,
get_current_user_by_api_key: create_user,
}
yield
app.dependency_overrides = {}

View file

@ -0,0 +1,6 @@
"""
Open WebUI Tools Package.
This package contains built-in tools that are automatically available
when native function calling is enabled.
"""

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@ def fill_missing_permissions(
def get_permissions(
user_id: str,
default_permissions: Dict[str, Any],
db: Optional[Any] = None,
) -> Dict[str, Any]:
"""
Get all permissions for a user by combining the permissions of all groups the user is a member of.
@ -53,7 +54,7 @@ def get_permissions(
) # Use the most permissive value (True > False)
return permissions
user_groups = Groups.get_groups_by_member_id(user_id)
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
# Deep copy default permissions to avoid modifying the original dict
permissions = json.loads(json.dumps(default_permissions))
@ -72,6 +73,7 @@ def has_permission(
user_id: str,
permission_key: str,
default_permissions: Dict[str, Any] = {},
db: Optional[Any] = None,
) -> bool:
"""
Check if a user has a specific permission by checking the group permissions
@ -92,7 +94,7 @@ def has_permission(
permission_hierarchy = permission_key.split(".")
# Retrieve user group permissions
user_groups = Groups.get_groups_by_member_id(user_id)
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
for group in user_groups:
if get_permission(group.permissions or {}, permission_hierarchy):
@ -127,6 +129,7 @@ def has_access(
access_control: Optional[dict] = None,
user_group_ids: Optional[Set[str]] = None,
strict: bool = True,
db: Optional[Any] = None,
) -> bool:
if access_control is None:
if strict:
@ -135,7 +138,7 @@ def has_access(
return True
if user_group_ids is None:
user_groups = Groups.get_groups_by_member_id(user_id)
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
permitted_ids = get_permitted_group_and_user_ids(type, access_control)
@ -152,10 +155,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
type: str = "write", access_control: Optional[dict] = None, db: Optional[Any] = None
) -> list[UserModel]:
if access_control is None:
result = Users.get_users(filter={"roles": ["!pending"]})
result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
return result.get("users", [])
permitted_ids = get_permitted_group_and_user_ids(type, access_control)
@ -167,8 +170,8 @@ def get_users_with_access(
user_ids_with_access = set(permitted_user_ids)
group_user_ids_map = Groups.get_group_user_ids_by_ids(permitted_group_ids)
group_user_ids_map = Groups.get_group_user_ids_by_ids(permitted_group_ids, db=db)
for user_ids in group_user_ids_map.values():
user_ids_with_access.update(user_ids)
return Users.get_users_by_user_ids(list(user_ids_with_access))
return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)

View file

@ -24,6 +24,8 @@ from opentelemetry import trace
from open_webui.utils.access_control import has_permission
from open_webui.models.users import Users
from open_webui.models.auths import Auths
from open_webui.constants import ERROR_MESSAGES
@ -42,6 +44,8 @@ from open_webui.env import (
from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
log = logging.getLogger(__name__)
@ -228,6 +232,10 @@ async def is_valid_token(request, decoded) -> bool:
async def invalidate_token(request, token):
decoded = decode_token(token)
# If token is invalid/expired, nothing to revoke
if not decoded:
return
# Require Redis to store revoked tokens
if request.app.state.redis:
jti = decoded.get("jti")
@ -271,6 +279,7 @@ async def get_current_user(
response: Response,
background_tasks: BackgroundTasks,
auth_token: HTTPAuthorizationCredentials = Depends(bearer_security),
db: Session = Depends(get_session),
):
token = None
@ -285,7 +294,7 @@ async def get_current_user(
# auth by api key
if token.startswith("sk-"):
user = get_current_user_by_api_key(request, token)
user = get_current_user_by_api_key(request, token, db=db)
# Add user info to current span
current_span = trace.get_current_span()
@ -314,7 +323,7 @@ async def get_current_user(
detail="Invalid token",
)
user = Users.get_user_by_id(data["id"])
user = Users.get_user_by_id(data["id"], db=db)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -364,8 +373,8 @@ async def get_current_user(
raise e
def get_current_user_by_api_key(request, api_key: str):
user = Users.get_user_by_api_key(api_key)
def get_current_user_by_api_key(request, api_key: str, db: Session = None):
user = Users.get_user_by_api_key(api_key, db=db)
if user is None:
raise HTTPException(
@ -393,7 +402,7 @@ def get_current_user_by_api_key(request, api_key: str):
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "api_key")
Users.update_last_active_by_id(user.id)
Users.update_last_active_by_id(user.id, db=db)
return user
@ -413,3 +422,37 @@ def get_admin_user(user=Depends(get_current_user)):
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return user
def create_admin_user(email: str, password: str, name: str = "Admin"):
"""
Create an admin user from environment variables.
Used for headless/automated deployments.
Returns the created user or None if creation failed.
"""
if not email or not password:
return None
if Users.has_users():
log.debug("Users already exist, skipping admin creation")
return None
log.info(f"Creating admin account from environment variables: {email}")
try:
hashed = get_password_hash(password)
user = Auths.insert_new_auth(
email=email.lower(),
password=hashed,
name=name,
role="admin",
)
if user:
log.info(f"Admin account created successfully: {email}")
return user
else:
log.error("Failed to create admin account from environment variables")
return None
except Exception as e:
log.error(f"Error creating admin account: {e}")
return None

View file

@ -166,6 +166,7 @@ async def generate_chat_completion(
form_data: dict,
user: Any,
bypass_filter: bool = False,
bypass_system_prompt: bool = False,
):
log.debug(f"generate_chat_completion: {form_data}")
if BYPASS_MODEL_ACCESS_CONTROL:
@ -237,7 +238,11 @@ async def generate_chat_completion(
yield chunk
response = await generate_chat_completion(
request, form_data, user, bypass_filter=True
request,
form_data,
user,
bypass_filter=True,
bypass_system_prompt=bypass_system_prompt,
)
return StreamingResponse(
stream_wrapper(response.body_iterator),
@ -248,7 +253,11 @@ async def generate_chat_completion(
return {
**(
await generate_chat_completion(
request, form_data, user, bypass_filter=True
request,
form_data,
user,
bypass_filter=True,
bypass_system_prompt=bypass_system_prompt,
)
),
"selected_model_id": selected_model_id,
@ -267,6 +276,7 @@ async def generate_chat_completion(
form_data=form_data,
user=user,
bypass_filter=bypass_filter,
bypass_system_prompt=bypass_system_prompt,
)
if form_data.get("stream"):
response.headers["content-type"] = "text/event-stream"
@ -283,6 +293,7 @@ async def generate_chat_completion(
form_data=form_data,
user=user,
bypass_filter=bypass_filter,
bypass_system_prompt=bypass_system_prompt,
)
@ -310,7 +321,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
try:
data = await process_pipeline_outlet_filter(request, data, user, models)
except Exception as e:
return Exception(f"Error: {e}")
raise Exception(f"Error: {e}")
metadata = {
"chat_id": data["chat_id"],
@ -346,7 +357,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
)
return result
except Exception as e:
return Exception(f"Error: {e}")
raise Exception(f"Error: {e}")
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
@ -442,6 +453,6 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
data = action(**params)
except Exception as e:
return Exception(f"Error: {e}")
raise Exception(f"Error: {e}")
return data

View file

@ -28,9 +28,7 @@ def has_permission(db, DocumentModel, query, filter: dict, permission: str = "re
for gid in group_ids:
if dialect_name == "sqlite":
group_read_conditions.append(
DocumentModel.access_control["read"]["group_ids"].contains(
[gid]
)
DocumentModel.access_control["read"]["group_ids"].contains(gid)
)
elif dialect_name == "postgresql":
group_read_conditions.append(
@ -63,9 +61,7 @@ def has_permission(db, DocumentModel, query, filter: dict, permission: str = "re
for gid in group_ids:
if dialect_name == "sqlite":
group_write_conditions.append(
DocumentModel.access_control["write"]["group_ids"].contains(
[gid]
)
DocumentModel.access_control["write"]["group_ids"].contains(gid)
)
elif dialect_name == "postgresql":
group_write_conditions.append(
@ -111,9 +107,7 @@ def has_permission(db, DocumentModel, query, filter: dict, permission: str = "re
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
DocumentModel.access_control[permission]["group_ids"].contains(
[gid]
)
DocumentModel.access_control[permission]["group_ids"].contains(gid)
)
elif dialect_name == "postgresql":
group_conditions.append(

View file

@ -7,6 +7,7 @@ log = logging.getLogger(__name__)
def apply_default_group_assignment(
default_group_id: str,
user_id: str,
db=None,
) -> None:
"""
Apply default group assignment to a user if default_group_id is provided.
@ -17,7 +18,7 @@ def apply_default_group_assignment(
"""
if default_group_id:
try:
Groups.add_users_to_group(default_group_id, [user_id])
Groups.add_users_to_group(default_group_id, [user_id], db=db)
except Exception as e:
log.error(
f"Failed to add user {user_id} to default group {default_group_id}: {e}"

View file

@ -64,8 +64,8 @@ def get_history(prompt_id, base_url, api_key):
return json.loads(response.read())
def get_images(ws, prompt, client_id, base_url, api_key):
prompt_id = queue_prompt(prompt, client_id, base_url, api_key)["prompt_id"]
def get_images(ws, workflow, client_id, base_url, api_key):
prompt_id = queue_prompt(workflow, client_id, base_url, api_key)["prompt_id"]
output_images = []
while True:
out = ws.recv()
@ -79,9 +79,12 @@ def get_images(ws, prompt, client_id, base_url, api_key):
continue # previews are binary data
history = get_history(prompt_id, base_url, api_key)[prompt_id]
for o in history["outputs"]:
for node_id in history["outputs"]:
node_output = history["outputs"][node_id]
for node_id in history["outputs"]:
node_output = history["outputs"][node_id]
if node_id in workflow and workflow[node_id].get("class_type") in [
"SaveImage",
"PreviewImage",
]:
if "images" in node_output:
for image in node_output["images"]:
url = get_image_url(

View file

@ -6,11 +6,13 @@ from typing import TYPE_CHECKING
from loguru import logger
from opentelemetry import trace
from open_webui.env import (
AUDIT_UVICORN_LOGGER_NAMES,
ENABLE_AUDIT_STDOUT,
ENABLE_AUDIT_LOGS_FILE,
AUDIT_LOGS_FILE_PATH,
AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL,
AUDIT_LOGS_FILE_PATH,
GLOBAL_LOG_LEVEL,
AUDIT_UVICORN_LOGGER_NAMES,
ENABLE_OTEL,
ENABLE_OTEL_LOGS,
)
@ -130,9 +132,11 @@ def start_logger():
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=lambda record: "auditable" not in record["extra"],
filter=lambda record: (
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
),
)
if AUDIT_LOG_LEVEL != "NONE":
if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE:
try:
logger.add(
AUDIT_LOGS_FILE_PATH,

View file

@ -44,6 +44,7 @@ from open_webui.routers.retrieval import (
process_web_search,
SearchForm,
)
from open_webui.utils.tools import get_builtin_tools
from open_webui.routers.images import (
image_generations,
CreateImageForm,
@ -92,7 +93,11 @@ from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
get_content_from_message,
)
from open_webui.utils.tools import get_tools, get_updated_tool_function
from open_webui.utils.tools import (
get_tools,
get_updated_tool_function,
has_tool_server_access,
)
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.filter import (
get_sorted_filter_ids,
@ -118,6 +123,7 @@ from open_webui.env import (
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_REALTIME_CHAT_SAVE,
ENABLE_QUERIES_CACHE,
RAG_SYSTEM_CONTEXT,
)
from open_webui.constants import TASKS
@ -140,6 +146,196 @@ DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
def get_citation_source_from_tool_result(
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ""
) -> list[dict]:
"""
Parse a tool's result and convert it to source dicts for citation display.
Follows the source format conventions from get_sources_from_items:
- source: file/item info object with id, name, type
- document: list of document contents
- metadata: list of metadata objects with source, file_id, name fields
Returns a list of sources (usually one, but query_knowledge_files may return multiple).
"""
try:
if tool_name == "search_web":
# Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}]
results = json.loads(tool_result)
documents = []
metadata = []
for result in results:
title = result.get("title", "")
link = result.get("link", "")
snippet = result.get("snippet", "")
documents.append(f"{title}\n{snippet}")
metadata.append(
{
"source": link,
"name": title,
"url": link,
}
)
return [
{
"source": {"name": "search_web", "id": "search_web"},
"document": documents,
"metadata": metadata,
}
]
elif tool_name == "view_knowledge_file":
file_data = json.loads(tool_result)
filename = file_data.get("filename", "Unknown File")
file_id = file_data.get("id", "")
knowledge_name = file_data.get("knowledge_name", "")
return [
{
"source": {
"id": file_id,
"name": filename,
"type": "file",
},
"document": [file_data.get("content", "")],
"metadata": [
{
"file_id": file_id,
"name": filename,
"source": filename,
**(
{"knowledge_name": knowledge_name}
if knowledge_name
else {}
),
}
],
}
]
elif tool_name == "query_knowledge_files":
chunks = json.loads(tool_result)
# Group chunks by source for better citation display
# Each unique source becomes a separate source entry
sources_by_file = {}
for chunk in chunks:
source_name = chunk.get("source", "Unknown")
file_id = chunk.get("file_id", "")
note_id = chunk.get("note_id", "")
chunk_type = chunk.get("type", "file")
content = chunk.get("content", "")
# Use file_id or note_id as the key
key = file_id or note_id or source_name
if key not in sources_by_file:
sources_by_file[key] = {
"source": {
"id": file_id or note_id,
"name": source_name,
"type": chunk_type,
},
"document": [],
"metadata": [],
}
sources_by_file[key]["document"].append(content)
sources_by_file[key]["metadata"].append(
{
"file_id": file_id,
"name": source_name,
"source": source_name,
**({"note_id": note_id} if note_id else {}),
}
)
# Return all grouped sources as a list
if sources_by_file:
return list(sources_by_file.values())
# Empty result fallback
return []
else:
# Fallback for other tools
return [
{
"source": {
"name": tool_name,
"type": "tool",
"id": tool_id or tool_name,
},
"document": [str(tool_result)],
"metadata": [{"source": tool_name, "name": tool_name}],
}
]
except Exception as e:
log.exception(f"Error parsing tool result for {tool_name}: {e}")
return [
{
"source": {"name": tool_name, "type": "tool"},
"document": [str(tool_result)],
"metadata": [{"source": tool_name}],
}
]
def apply_source_context_to_messages(
request: Request,
messages: list,
sources: list,
user_message: str,
) -> list:
"""
Build source context from citation sources and apply to messages.
Uses RAG template to format context for model consumption.
"""
if not sources or not user_message:
return messages
context_string = ""
citation_idx = {}
for source in sources:
for doc, meta in zip(source.get("document", []), source.get("metadata", [])):
src_id = meta.get("source") or source.get("source", {}).get("id") or "N/A"
if src_id not in citation_idx:
citation_idx[src_id] = len(citation_idx) + 1
src_name = source.get("source", {}).get("name")
context_string += (
f'<source id="{citation_idx[src_id]}"'
+ (f' name="{src_name}"' if src_name else "")
+ f">{doc}</source>\n"
)
context_string = context_string.strip()
if not context_string:
return messages
if RAG_SYSTEM_CONTEXT:
return add_or_update_system_message(
rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, user_message
),
messages,
append=True,
)
else:
return add_or_update_user_message(
rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, user_message
),
messages,
append=False,
)
def process_tool_result(
request,
tool_function_name,
@ -724,6 +920,8 @@ def get_images_from_messages(message_list):
for file in message.get("files", []):
if file.get("type") == "image":
message_images.append(file.get("url"))
elif file.get("content_type", "").startswith("image/"):
message_images.append(file.get("url"))
if message_images:
images.append(message_images)
@ -752,6 +950,51 @@ def get_image_urls(delta_images, request, metadata, user) -> list[str]:
return image_urls
def add_file_context(messages: list, chat_id: str, user) -> list:
"""
Add file URLs to messages for native function calling.
"""
if not chat_id or chat_id.startswith("local:"):
return messages
chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
if not chat:
return messages
history = chat.chat.get("history", {})
stored_messages = get_message_list(
history.get("messages", {}), history.get("currentId")
)
def format_file_tag(file):
attrs = f'type="{file.get("type", "file")}" url="{file["url"]}"'
if file.get("content_type"):
attrs += f' content_type="{file["content_type"]}"'
if file.get("name"):
attrs += f' name="{file["name"]}"'
return f"<file {attrs}/>"
for message, stored_message in zip(messages, stored_messages):
files_with_urls = [
file for file in stored_message.get("files", []) if file.get("url")
]
if not files_with_urls:
continue
file_tags = [format_file_tag(file) for file in files_with_urls]
file_context = (
"<attached_files>\n" + "\n".join(file_tags) + "\n</attached_files>\n\n"
)
content = message.get("content", "")
if isinstance(content, list):
message["content"] = [{"type": "text", "text": file_context}] + content
else:
message["content"] = file_context + content
return messages
async def chat_image_generation_handler(
request: Request, form_data: dict, extra_params: dict, user
):
@ -1196,6 +1439,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__oauth_token__": oauth_token,
"__request__": request,
"__model__": model,
"__chat_id__": metadata.get("chat_id"),
"__message_id__": metadata.get("message_id"),
}
# Initialize events to store additional event to be sent to the client
# Initialize contexts and citation
@ -1239,7 +1484,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
user_message = get_last_user_message(form_data["messages"])
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
if model_knowledge:
if (
model_knowledge
and metadata.get("params", {}).get("function_calling") != "native"
):
await event_emitter(
{
"type": "status",
@ -1305,7 +1553,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
except Exception as e:
raise Exception(f"{e}")
features = form_data.pop("features", None)
features = form_data.pop("features", None) or {}
extra_params["__features__"] = features
if features:
if "voice" in features and features["voice"]:
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None:
@ -1320,19 +1569,25 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
if "memory" in features and features["memory"]:
form_data = await chat_memory_handler(
request, form_data, extra_params, user
)
# Skip forced memory injection when native FC is enabled - model can use memory tools
if metadata.get("params", {}).get("function_calling") != "native":
form_data = await chat_memory_handler(
request, form_data, extra_params, user
)
if "web_search" in features and features["web_search"]:
form_data = await chat_web_search_handler(
request, form_data, extra_params, user
)
# Skip forced RAG web search when native FC is enabled - model can use web_search tool
if metadata.get("params", {}).get("function_calling") != "native":
form_data = await chat_web_search_handler(
request, form_data, extra_params, user
)
if "image_generation" in features and features["image_generation"]:
form_data = await chat_image_generation_handler(
request, form_data, extra_params, user
)
# Skip forced image generation when native FC is enabled - model can use generate_image tool
if metadata.get("params", {}).get("function_calling") != "native":
form_data = await chat_image_generation_handler(
request, form_data, extra_params, user
)
if "code_interpreter" in features and features["code_interpreter"]:
form_data["messages"] = add_or_update_user_message(
@ -1412,6 +1667,13 @@ async def process_chat_payload(request, form_data, user, metadata, model):
log.error(f"MCP server with id {server_id} not found")
continue
# Check access control for MCP server
if not has_tool_server_access(user, mcp_server_connection):
log.warning(
f"Access denied to MCP server {server_id} for user {user.id}"
)
continue
auth_type = mcp_server_connection.get("auth_type", "")
headers = {}
if auth_type == "bearer":
@ -1543,6 +1805,36 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if mcp_clients:
metadata["mcp_clients"] = mcp_clients
# Inject builtin tools for native function calling based on enabled features and model capability
# Check if builtin_tools capability is enabled for this model (defaults to True if not specified)
builtin_tools_enabled = (
model.get("info", {})
.get("meta", {})
.get("capabilities", {})
.get("builtin_tools", True)
)
if (
metadata.get("params", {}).get("function_calling") == "native"
and builtin_tools_enabled
):
# Add file context to user messages
chat_id = metadata.get("chat_id")
form_data["messages"] = add_file_context(
form_data.get("messages", []), chat_id, user
)
builtin_tools = get_builtin_tools(
request,
{
**extra_params,
"__event_emitter__": event_emitter,
},
features,
model,
)
for name, tool_dict in builtin_tools.items():
if name not in tools_dict:
tools_dict[name] = tool_dict
if tools_dict:
if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
@ -1551,6 +1843,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
try:
@ -1561,54 +1854,28 @@ async def process_chat_payload(request, form_data, user, metadata, model):
except Exception as e:
log.exception(e)
try:
form_data, flags = await chat_completion_files_handler(
request, form_data, extra_params, user
)
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
# Check if file context extraction is enabled for this model (default True)
file_context_enabled = (
model.get("info", {})
.get("meta", {})
.get("capabilities", {})
.get("file_context", True)
)
if file_context_enabled:
try:
form_data, flags = await chat_completion_files_handler(
request, form_data, extra_params, user
)
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
# If context is not empty, insert it into the messages
if len(sources) > 0:
context_string = ""
citation_idx_map = {}
for source in sources:
if "document" in source:
for document_text, document_metadata in zip(
source["document"], source["metadata"]
):
source_name = source.get("source", {}).get("name", None)
source_id = (
document_metadata.get("source", None)
or source.get("source", {}).get("id", None)
or "N/A"
)
if source_id not in citation_idx_map:
citation_idx_map[source_id] = len(citation_idx_map) + 1
context_string += (
f'<source id="{citation_idx_map[source_id]}"'
+ (f' name="{source_name}"' if source_name else "")
+ f">{document_text}</source>\n"
)
context_string = context_string.strip()
if prompt is None:
raise Exception("No user message found")
if context_string != "":
form_data["messages"] = add_or_update_user_message(
rag_template(
request.app.state.config.RAG_TEMPLATE,
context_string,
prompt,
),
form_data["messages"],
append=False,
)
if sources and prompt:
form_data["messages"] = apply_source_context_to_messages(
request, form_data["messages"], sources, prompt
)
# If there are citations, add them to the data_items
sources = [
@ -2605,8 +2872,42 @@ async def process_chat_response(
continue
delta = choices[0].get("delta", {})
delta_tool_calls = delta.get("tool_calls", None)
# Handle delta annotations
annotations = delta.get("annotations")
if annotations:
for annotation in annotations:
if (
annotation.get("type") == "url_citation"
and "url_citation" in annotation
):
url_citation = annotation[
"url_citation"
]
url = url_citation.get("url", "")
title = url_citation.get("title", url)
await event_emitter(
{
"type": "source",
"data": {
"source": {
"name": title,
"url": url,
},
"document": [title],
"metadata": [
{
"source": url,
"name": title,
}
],
},
}
)
delta_tool_calls = delta.get("tool_calls", None)
if delta_tool_calls:
for delta_tool_call in delta_tool_calls:
tool_call_index = delta_tool_call.get(
@ -2665,6 +2966,29 @@ async def process_chat_response(
"arguments"
] += delta_arguments
# Emit pending tool calls in real-time
if response_tool_calls:
# Flush any pending text first
await flush_pending_delta_data()
pending_content_blocks = content_blocks + [
{
"type": "tool_calls",
"content": response_tool_calls,
"pending": True,
}
]
await event_emitter(
{
"type": "chat:completion",
"data": {
"content": serialize_content_blocks(
pending_content_blocks
),
},
}
)
image_urls = get_image_urls(
delta.get("images", []), request, metadata, user
)
@ -2878,6 +3202,7 @@ async def process_chat_response(
await stream_body_handler(response, form_data)
tool_call_retries = 0
tool_call_sources = [] # Track citation sources from tool results
while (
len(tool_calls) > 0
@ -3012,6 +3337,27 @@ async def process_chat_response(
)
)
# Extract citation sources from tool results
if (
tool_function_name
in [
"search_web",
"view_knowledge_file",
"query_knowledge_files",
]
and tool_result
):
try:
citation_sources = get_citation_source_from_tool_result(
tool_name=tool_function_name,
tool_params=tool_function_params,
tool_result=tool_result,
tool_id=tool.get("tool_id", "") if tool else "",
)
tool_call_sources.extend(citation_sources)
except Exception as e:
log.exception(f"Error extracting citation source: {e}")
results.append(
{
"tool_call_id": tool_call_id,
@ -3037,6 +3383,22 @@ async def process_chat_response(
}
)
# Emit citation sources for UI display
for source in tool_call_sources:
await event_emitter({"type": "source", "data": source})
# Apply source context to messages for model
if tool_call_sources:
user_msg = get_last_user_message(form_data["messages"])
if user_msg:
form_data["messages"] = apply_source_context_to_messages(
request,
form_data["messages"],
tool_call_sources,
user_msg,
)
tool_call_sources.clear()
await event_emitter(
{
"type": "chat:completion",
@ -3063,6 +3425,7 @@ async def process_chat_response(
request,
new_form_data,
user,
bypass_system_prompt=True,
)
if isinstance(res, StreamingResponse):
@ -3242,6 +3605,7 @@ async def process_chat_response(
request,
new_form_data,
user,
bypass_system_prompt=True,
)
if isinstance(res, StreamingResponse):

View file

@ -337,7 +337,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
return models
def check_model_access(user, model):
def check_model_access(user, model, db=None):
if model.get("arena"):
if not has_access(
user.id,
@ -345,22 +345,23 @@ def check_model_access(user, model):
access_control=model.get("info", {})
.get("meta", {})
.get("access_control", {}),
db=db,
):
raise Exception("Model not found")
else:
model_info = Models.get_model_by_id(model.get("id"))
model_info = Models.get_model_by_id(model.get("id"), db=db)
if not model_info:
raise Exception("Model not found")
elif not (
user.id == model_info.user_id
or has_access(
user.id, type="read", access_control=model_info.access_control
user.id, type="read", access_control=model_info.access_control, db=db
)
):
raise Exception("Model not found")
def get_filtered_models(models, user):
def get_filtered_models(models, user, db=None):
# Filter out models that the user does not have access to
if (
user.role == "user"
@ -373,7 +374,9 @@ def get_filtered_models(models, user):
}
filtered_models = []
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for model in models:
if model.get("arena"):
if has_access(

View file

@ -246,10 +246,66 @@ def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
return parsed, base_url
def get_discovery_urls(server_url) -> list[str]:
parsed, base_url = get_parsed_and_base_url(server_url)
async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
"""
https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
"""
urls = []
authorization_servers = []
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
server_url,
json={"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1},
headers={"Content-Type": "application/json"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
if response.status == 401:
match = re.search(
r'resource_metadata="([^"]+)"',
response.headers.get("WWW-Authenticate", ""),
)
if match:
resource_metadata_url = match.group(1)
log.debug(
f"Found resource_metadata URL: {resource_metadata_url}"
)
# Step 2: Fetch Protected Resource metadata
async with session.get(
resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resource_response:
if resource_response.status == 200:
resource_metadata = await resource_response.json()
# Step 3: Extract authorization_servers
servers = resource_metadata.get(
"authorization_servers", []
)
if servers:
authorization_servers = servers
log.debug(
f"Discovered authorization servers: {servers}"
)
except Exception as e:
log.debug(f"MCP Protected Resource discovery failed: {e}")
discovery_urls = []
for auth_server in authorization_servers:
auth_server = auth_server.rstrip("/")
discovery_urls.extend(
[
f"{auth_server}/.well-known/oauth-authorization-server",
f"{auth_server}/.well-known/openid-configuration",
]
)
return discovery_urls
async def get_discovery_urls(server_url) -> list[str]:
urls = await get_authorization_server_discovery_urls(server_url)
parsed, base_url = get_parsed_and_base_url(server_url)
if parsed.path and parsed.path != "/":
# Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery
@ -303,7 +359,7 @@ async def get_oauth_client_info_with_dynamic_client_registration(
)
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
discovery_urls = get_discovery_urls(oauth_server_url)
discovery_urls = await get_discovery_urls(oauth_server_url)
for url in discovery_urls:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
@ -579,24 +635,27 @@ class OAuthClientManager:
def get_client(self, client_id):
if client_id not in self.clients:
self.ensure_client_from_config(client_id)
client = self.clients.get(client_id)
return client["client"] if client else None
def get_client_info(self, client_id):
if client_id not in self.clients:
self.ensure_client_from_config(client_id)
client = self.clients.get(client_id)
return client["client_info"] if client else None
def get_server_metadata_url(self, client_id):
if client_id in self.clients:
client = self.clients[client_id]
return (
client._server_metadata_url
if hasattr(client, "_server_metadata_url")
else None
)
return None
client = self.get_client(client_id)
if not client:
return None
return (
client._server_metadata_url
if hasattr(client, "_server_metadata_url")
else None
)
async def get_oauth_token(
self, user_id: str, client_id: str, force_refresh: bool = False
@ -1132,7 +1191,7 @@ class OAuthManager:
return role
def update_user_groups(self, user, user_data, default_permissions):
def update_user_groups(self, user, user_data, default_permissions, db=None):
log.debug("Running OAUTH Group management")
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
@ -1161,8 +1220,10 @@ class OAuthManager:
else:
user_oauth_groups = []
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
all_available_groups: list[GroupModel] = Groups.get_all_groups()
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(
user.id, db=db
)
all_available_groups: list[GroupModel] = Groups.get_all_groups(db=db)
# Create groups if they don't exist and creation is enabled
if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION:
@ -1188,7 +1249,7 @@ class OAuthManager:
)
# Use determined creator ID (admin or fallback to current user)
created_group = Groups.insert_new_group(
creator_id, new_group_form
creator_id, new_group_form, db=db
)
if created_group:
log.info(
@ -1206,7 +1267,7 @@ class OAuthManager:
# Refresh the list of all available groups if any were created
if groups_created:
all_available_groups = Groups.get_all_groups()
all_available_groups = Groups.get_all_groups(db=db)
log.debug("Refreshed list of all available groups after creation.")
log.debug(f"Oauth Groups claim: {oauth_claim}")
@ -1227,7 +1288,7 @@ class OAuthManager:
log.debug(
f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
)
Groups.remove_users_from_group(group_model.id, [user.id])
Groups.remove_users_from_group(group_model.id, [user.id], db=db)
# In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions
@ -1242,6 +1303,7 @@ class OAuthManager:
permissions=group_permissions,
),
overwrite=False,
db=db,
)
# Add user to new groups
@ -1257,7 +1319,7 @@ class OAuthManager:
f"Adding user to group {group_model.name} as it was found in their oauth groups"
)
Groups.add_users_to_group(group_model.id, [user.id])
Groups.add_users_to_group(group_model.id, [user.id], db=db)
# In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions
@ -1272,6 +1334,7 @@ class OAuthManager:
permissions=group_permissions,
),
overwrite=False,
db=db,
)
async def _process_picture_url(
@ -1336,7 +1399,7 @@ class OAuthManager:
return await client.authorize_redirect(request, redirect_uri, **kwargs)
async def handle_callback(self, request, provider, response):
async def handle_callback(self, request, provider, response, db=None):
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
@ -1461,20 +1524,20 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Check if the user exists
user = Users.get_user_by_oauth_sub(provider, sub)
user = Users.get_user_by_oauth_sub(provider, sub, db=db)
if not user:
# If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
# Check if the user exists by email
user = Users.get_user_by_email(email)
user = Users.get_user_by_email(email, db=db)
if user:
# Update the user with the new oauth sub
Users.update_user_oauth_by_id(user.id, provider, sub)
Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
if user:
determined_role = self.get_user_role(user, user_data)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
Users.update_user_role_by_id(user.id, determined_role, db=db)
# Update the user object in memory as well,
# to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below
user.role = determined_role
@ -1491,14 +1554,14 @@ class OAuthManager:
)
if processed_picture_url != user.profile_image_url:
Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url
user.id, processed_picture_url, db=db
)
log.debug(f"Updated profile picture for user {user.email}")
else:
# If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email(email)
existing_user = Users.get_user_by_email(email, db=db)
if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
@ -1529,6 +1592,7 @@ class OAuthManager:
profile_image_url=picture_url,
role=self.get_user_role(None, user_data),
oauth=oauth_data,
db=db,
)
if auth_manager_config.WEBHOOK_URL:
@ -1544,8 +1608,7 @@ class OAuthManager:
)
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db
)
else:
@ -1566,6 +1629,7 @@ class OAuthManager:
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
db=db,
)
except Exception as e:
@ -1616,15 +1680,16 @@ class OAuthManager:
token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
# Clean up any existing sessions for this user/provider first
sessions = OAuthSessions.get_sessions_by_user_id(user.id)
sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db)
for session in sessions:
if session.provider == provider:
OAuthSessions.delete_session_by_id(session.id)
OAuthSessions.delete_session_by_id(session.id, db=db)
session = OAuthSessions.create_session(
user_id=user.id,
provider=provider,
token=token,
db=db,
)
response.set_cookie(

View file

@ -7,7 +7,7 @@ import types
import tempfile
import logging
from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS
from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE
from open_webui.models.functions import Functions
from open_webui.models.tools import Tools
@ -264,6 +264,10 @@ def get_function_module_from_cache(request, function_id, load_from_db=True):
def install_frontmatter_requirements(requirements: str):
if OFFLINE_MODE:
log.info("Offline mode enabled, skipping installation of requirements.")
return
if requirements:
try:
req_list = [req.strip() for req in requirements.split(",")]

View file

@ -42,6 +42,38 @@ class SentinelRedisProxy:
return orig_attr
if self._async_mode:
if inspect.isasyncgenfunction(orig_attr):
def _wrapped_iter(*args, **kwargs):
async def _iter():
for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
try:
method = getattr(self._master(), item)
async for value in method(*args, **kwargs):
yield value
return
except (
redis.exceptions.ConnectionError,
redis.exceptions.ReadOnlyError,
) as e:
if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
log.debug(
"Redis sentinel fail-over (%s). Retry %s/%s",
type(e).__name__,
i + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
)
continue
log.error(
"Redis operation failed after %s retries: %s",
REDIS_SENTINEL_MAX_RETRY_COUNT,
e,
)
raise e from e
return _iter()
return _wrapped_iter
async def _wrapped(*args, **kwargs):
for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT):

View file

@ -37,12 +37,42 @@ from langchain_core.utils.function_calling import (
from open_webui.utils.misc import is_string_allowed
from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.models.groups import Groups
from open_webui.utils.plugin import load_tool_module_by_id
from open_webui.utils.access_control import has_access
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
)
from open_webui.tools.builtin import (
search_web,
fetch_url,
generate_image,
edit_image,
search_memories,
add_memory,
replace_memory_content,
get_current_timestamp,
calculate_timestamp,
search_notes,
search_chats,
search_channels,
search_channel_messages,
view_note,
view_chat,
view_channel_message,
view_channel_thread,
replace_note_content,
write_note,
list_knowledge_bases,
search_knowledge_bases,
query_knowledge_bases,
search_knowledge_files,
query_knowledge_files,
view_knowledge_file,
)
import copy
@ -104,15 +134,114 @@ def get_updated_tool_function(function: Callable, extra_params: dict):
return function
def has_tool_server_access(
user: UserModel, server_connection: dict, user_group_ids: set = None
) -> bool:
"""Check if user has access to a tool server (MCP or OpenAPI)."""
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return True
if user_group_ids is None:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
access_control = server_connection.get("config", {}).get("access_control", None)
return has_access(user.id, "read", access_control, user_group_ids)
async def get_tools(
request: Request, tool_ids: list[str], user: UserModel, extra_params: dict
) -> dict[str, dict]:
"""Load tools for the given tool_ids, checking access control."""
tools_dict = {}
# Get user's group memberships for access control checks
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
for tool_id in tool_ids:
tool = Tools.get_tool_by_id(tool_id)
if tool is None:
if tool:
# Check access control for local tools
if (
not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
and tool.user_id != user.id
and not has_access(user.id, "read", tool.access_control, user_group_ids)
):
log.warning(f"Access denied to tool {tool_id} for user {user.id}")
continue
module = request.app.state.TOOLS.get(tool_id, None)
if module is None:
module, _ = load_tool_module_by_id(tool_id)
request.app.state.TOOLS[tool_id] = module
__user__ = {
**extra_params["__user__"],
}
# Set valves for the tool
if hasattr(module, "valves") and hasattr(module, "Valves"):
valves = Tools.get_tool_valves_by_id(tool_id) or {}
module.valves = module.Valves(**valves)
if hasattr(module, "UserValves"):
__user__["valves"] = module.UserValves( # type: ignore
**Tools.get_user_valves_by_id_and_user_id(tool_id, user.id)
)
for spec in tool.specs:
# TODO: Fix hack for OpenAI API
# Some times breaks OpenAI but others don't. Leaving the comment
for val in spec.get("parameters", {}).get("properties", {}).values():
if val.get("type") == "str":
val["type"] = "string"
# Remove internal reserved parameters (e.g. __id__, __user__)
spec["parameters"]["properties"] = {
key: val
for key, val in spec["parameters"]["properties"].items()
if not key.startswith("__")
}
# convert to function that takes only model params and inserts custom params
function_name = spec["name"]
tool_function = getattr(module, function_name)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{
**extra_params,
"__id__": tool_id,
"__user__": __user__,
},
)
# TODO: Support Pydantic models as parameters
if callable.__doc__ and callable.__doc__.strip() != "":
s = re.split(":(param|return)", callable.__doc__, 1)
spec["description"] = s[0]
else:
spec["description"] = function_name
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
# Misc info
"metadata": {
"file_handler": hasattr(module, "file_handler")
and module.file_handler,
"citation": hasattr(module, "citation") and module.citation,
},
}
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend tool ID to function name
function_name = f"{tool_id}_{function_name}"
tools_dict[function_name] = tool_dict
else:
if tool_id.startswith("server:"):
splits = tool_id.split(":")
@ -147,6 +276,15 @@ async def get_tools(
]
)
# Check access control for tool server
if not has_tool_server_access(
user, tool_server_connection, user_group_ids
):
log.warning(
f"Access denied to tool server {server_id} for user {user.id}"
)
continue
specs = tool_server_data.get("specs", [])
function_name_filter_list = tool_server_connection.get(
"config", {}
@ -241,81 +379,116 @@ async def get_tools(
else:
continue
else:
continue
else:
module = request.app.state.TOOLS.get(tool_id, None)
if module is None:
module, _ = load_tool_module_by_id(tool_id)
request.app.state.TOOLS[tool_id] = module
return tools_dict
__user__ = {
**extra_params["__user__"],
}
# Set valves for the tool
if hasattr(module, "valves") and hasattr(module, "Valves"):
valves = Tools.get_tool_valves_by_id(tool_id) or {}
module.valves = module.Valves(**valves)
if hasattr(module, "UserValves"):
__user__["valves"] = module.UserValves( # type: ignore
**Tools.get_user_valves_by_id_and_user_id(tool_id, user.id)
)
def get_builtin_tools(
request: Request, extra_params: dict, features: dict = None, model: dict = None
) -> dict[str, dict]:
"""
Get built-in tools for native function calling.
Only returns tools when BOTH the global config is enabled AND the model capability allows it.
"""
tools_dict = {}
builtin_functions = []
features = features or {}
model = model or {}
for spec in tool.specs:
# TODO: Fix hack for OpenAI API
# Some times breaks OpenAI but others don't. Leaving the comment
for val in spec.get("parameters", {}).get("properties", {}).values():
if val.get("type") == "str":
val["type"] = "string"
# Helper to get model capabilities (defaults to True if not specified)
def get_model_capability(name: str, default: bool = True) -> bool:
return (
model.get("info", {})
.get("meta", {})
.get("capabilities", {})
.get(name, default)
)
# Remove internal reserved parameters (e.g. __id__, __user__)
spec["parameters"]["properties"] = {
key: val
for key, val in spec["parameters"]["properties"].items()
if not key.startswith("__")
}
# Time utilities - always available for date calculations
builtin_functions.extend([get_current_timestamp, calculate_timestamp])
# convert to function that takes only model params and inserts custom params
function_name = spec["name"]
tool_function = getattr(module, function_name)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{
**extra_params,
"__id__": tool_id,
"__user__": __user__,
},
)
# Knowledge base tools - conditional injection based on model knowledge
# If model has attached knowledge (any type), only provide query_knowledge_files
# Otherwise, provide all KB browsing tools
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", [])
if model_knowledge:
# Model has attached knowledge - only allow semantic search within it
builtin_functions.append(query_knowledge_files)
else:
# No model knowledge - allow full KB browsing
builtin_functions.extend(
[
list_knowledge_bases,
search_knowledge_bases,
query_knowledge_bases,
search_knowledge_files,
query_knowledge_files,
view_knowledge_file,
]
)
# TODO: Support Pydantic models as parameters
if callable.__doc__ and callable.__doc__.strip() != "":
s = re.split(":(param|return)", callable.__doc__, 1)
spec["description"] = s[0]
else:
spec["description"] = function_name
# Chats tools - search and fetch user's chat history
builtin_functions.extend([search_chats, view_chat])
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
# Misc info
"metadata": {
"file_handler": hasattr(module, "file_handler")
and module.file_handler,
"citation": hasattr(module, "citation") and module.citation,
},
}
# Add memory tools if enabled for this chat
if features.get("memory"):
builtin_functions.extend([search_memories, add_memory, replace_memory_content])
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend tool ID to function name
function_name = f"{tool_id}_{function_name}"
# Add web search tools if enabled globally AND model has web_search capability
if getattr(
request.app.state.config, "ENABLE_WEB_SEARCH", False
) and get_model_capability("web_search"):
builtin_functions.extend([search_web, fetch_url])
tools_dict[function_name] = tool_dict
# Add image generation/edit tools if enabled globally AND model has image_generation capability
if getattr(
request.app.state.config, "ENABLE_IMAGE_GENERATION", False
) and get_model_capability("image_generation"):
builtin_functions.append(generate_image)
if getattr(
request.app.state.config, "ENABLE_IMAGE_EDIT", False
) and get_model_capability("image_generation"):
builtin_functions.append(edit_image)
# Notes tools - search, view, create, and update user's notes (if notes enabled globally)
if getattr(request.app.state.config, "ENABLE_NOTES", False):
builtin_functions.extend(
[search_notes, view_note, write_note, replace_note_content]
)
# Channels tools - search channels and messages (if channels enabled globally)
if getattr(request.app.state.config, "ENABLE_CHANNELS", False):
builtin_functions.extend(
[
search_channels,
search_channel_messages,
view_channel_thread,
view_channel_message,
]
)
for func in builtin_functions:
callable = get_async_tool_function_and_apply_extra_params(
func,
{
"__request__": request,
"__user__": extra_params.get("__user__", {}),
"__event_emitter__": extra_params.get("__event_emitter__"),
"__chat_id__": extra_params.get("__chat_id__"),
"__message_id__": extra_params.get("__message_id__"),
"__model_knowledge__": model_knowledge,
},
)
# Generate spec from function
pydantic_model = convert_function_to_pydantic_model(func)
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
tools_dict[func.__name__] = {
"tool_id": f"builtin:{func.__name__}",
"callable": callable,
"spec": spec,
"type": "builtin",
}
return tools_dict

View file

@ -3,7 +3,7 @@ import logging
import aiohttp
from open_webui.config import WEBUI_FAVICON_URL
from open_webui.env import VERSION
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT, VERSION
log = logging.getLogger(__name__)
@ -50,7 +50,9 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b
payload = {**event_data}
log.debug(f"payload: {payload}")
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(url, json=payload) as r:
r_text = await r.text()
r.raise_for_status()

View file

@ -1,19 +1,19 @@
# Minimal requirements for backend to run
# WIP: use this as a reference to build a minimal docker image
fastapi==0.126.0
uvicorn[standard]==0.37.0
fastapi==0.128.0
uvicorn[standard]==0.40.0
pydantic==2.12.5
python-multipart==0.0.21
itsdangerous==2.2.0
python-socketio==5.15.1
python-socketio==5.16.0
python-jose==3.5.0
cryptography
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.10.1
authlib==1.6.5
authlib==1.6.6
requests==2.32.5
aiohttp==3.13.2
@ -32,7 +32,7 @@ peewee-migrate==1.14.3
pycrdt==0.12.44
redis
APScheduler==3.11.1
APScheduler==3.11.2
RestrictedPython==8.1
loguru==0.7.3
@ -41,11 +41,14 @@ asgiref==3.11.0
mcp==1.25.0
openai
langchain==0.3.27
langchain-community==0.3.29
langchain==1.2.0
langchain-community==0.4.1
langchain-classic==1.0.1
langchain-text-splitters==1.1.0
fake-useragent==2.2.0
chromadb==1.3.7
chromadb==1.4.0
black==25.12.0
pydub
chardet==5.2.0

View file

@ -1,10 +1,10 @@
fastapi==0.126.0
uvicorn[standard]==0.37.0
fastapi==0.128.0
uvicorn[standard]==0.40.0
pydantic==2.12.5
python-multipart==0.0.21
itsdangerous==2.2.0
python-socketio==5.15.1
python-socketio==5.16.0
python-jose==3.5.0
cryptography
bcrypt==5.0.0
@ -30,7 +30,7 @@ peewee-migrate==1.14.3
pycrdt==0.12.44
redis
APScheduler==3.11.1
APScheduler==3.11.2
RestrictedPython==8.1
loguru==0.7.3
@ -43,16 +43,15 @@ mcp==1.25.0
openai
anthropic
google-genai==1.56.0
google-generativeai==0.8.6
langchain==1.2.0
langchain-community==0.4.1
langchain-classic==1.0.0
langchain-classic==1.0.1
langchain-text-splitters==1.1.0
fake-useragent==2.2.0
chromadb==1.3.7
weaviate-client==4.19.0
chromadb==1.4.0
weaviate-client==4.19.2
opensearch-py==3.1.0
transformers==4.57.3
@ -65,10 +64,10 @@ ftfy==6.3.1
chardet==5.2.0
pypdf==6.5.0
fpdf2==2.8.5
pymdown-extensions==10.19.1
pymdown-extensions==10.20
docx2txt==0.9
python-pptx==1.0.2
unstructured==0.18.21
unstructured==0.18.24
msoffcrypto-tool==5.4.2
nltk==3.9.2
Markdown==3.10
@ -82,7 +81,7 @@ psutil
sentencepiece
soundfile==0.13.1
pillow==12.0.0
pillow==12.1.0
opencv-python-headless==4.12.0.88
rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2
@ -116,12 +115,12 @@ psycopg2-binary==2.9.11
pgvector==0.4.2
PyMySQL==1.1.2
boto3==1.42.14
boto3==1.42.21
pymilvus==2.6.5
pymilvus==2.6.6
qdrant-client==1.16.2
playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
elasticsearch==9.2.0
elasticsearch==9.2.1
pinecone==6.0.2
oracledb==3.4.1

100
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.6.43",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.6.43",
"version": "0.7.0",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@ -62,7 +62,7 @@
"i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1",
"js-sha256": "^0.10.1",
"jspdf": "^3.0.0",
"jspdf": "^4.0.0",
"katex": "^0.16.22",
"kokoro-js": "^1.1.1",
"leaflet": "^1.9.4",
@ -99,6 +99,7 @@
"vite-plugin-static-copy": "^2.2.0",
"xlsx": "^0.18.5",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yaml": "^2.7.1",
"yjs": "^13.6.27"
},
@ -128,7 +129,7 @@
"tailwindcss": "^4.0.0",
"tslib": "^2.4.1",
"typescript": "^5.5.4",
"vite": "^5.4.14",
"vite": "^5.4.21",
"vitest": "^1.6.1"
},
"engines": {
@ -217,13 +218,10 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
@ -4218,6 +4216,12 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@ -4900,18 +4904,6 @@
"node": ">= 4.0.0"
}
},
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
@ -5183,18 +5175,6 @@
"node": "10.* || >= 12.*"
}
},
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@ -7649,6 +7629,23 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-png/node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/fastq": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
@ -8649,6 +8646,12 @@
"node": ">=12"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
@ -9014,14 +9017,13 @@
}
},
"node_modules/jspdf": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz",
"integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz",
"integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.7",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
@ -11382,11 +11384,6 @@
"node": ">=8.10.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@ -13761,9 +13758,9 @@
}
},
"node_modules/vite": {
"version": "5.4.19",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
@ -14865,11 +14862,10 @@
}
},
"node_modules/y-protocols": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
"integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.85"
},

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.6.43",
"version": "0.7.0",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@ -47,7 +47,7 @@
"tailwindcss": "^4.0.0",
"tslib": "^2.4.1",
"typescript": "^5.5.4",
"vite": "^5.4.14",
"vite": "^5.4.21",
"vitest": "^1.6.1"
},
"type": "module",
@ -106,7 +106,7 @@
"i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1",
"js-sha256": "^0.10.1",
"jspdf": "^3.0.0",
"jspdf": "^4.0.0",
"katex": "^0.16.22",
"kokoro-js": "^1.1.1",
"leaflet": "^1.9.4",
@ -143,6 +143,7 @@
"vite-plugin-static-copy": "^2.2.0",
"xlsx": "^0.18.5",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yaml": "^2.7.1",
"yjs": "^13.6.27"
},

View file

@ -6,13 +6,13 @@ authors = [
]
license = { file = "LICENSE" }
dependencies = [
"fastapi==0.126.0",
"uvicorn[standard]==0.37.0",
"fastapi==0.128.0",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.5",
"python-multipart==0.0.21",
"itsdangerous==2.2.0",
"python-socketio==5.15.1",
"python-socketio==5.16.0",
"python-jose==3.5.0",
"cryptography",
"bcrypt==5.0.0",
@ -38,7 +38,7 @@ dependencies = [
"pycrdt==0.12.44",
"redis",
"APScheduler==3.11.1",
"APScheduler==3.11.2",
"RestrictedPython==8.1",
"loguru==0.7.3",
@ -50,18 +50,17 @@ dependencies = [
"openai",
"anthropic",
"google-genai==1.56.0",
"google-generativeai==0.8.6",
"langchain==1.2.0",
"langchain-community==0.4.1",
"langchain-classic==1.0.0",
"langchain-classic==1.0.1",
"langchain-text-splitters==1.1.0",
"fake-useragent==2.2.0",
"chromadb==1.3.7",
"chromadb==1.4.0",
"opensearch-py==3.1.0",
"PyMySQL==1.1.2",
"boto3==1.42.14",
"boto3==1.42.21",
"transformers==4.57.3",
"sentence-transformers==5.2.0",
@ -73,10 +72,10 @@ dependencies = [
"chardet==5.2.0",
"pypdf==6.5.0",
"fpdf2==2.8.5",
"pymdown-extensions==10.19.1",
"pymdown-extensions==10.20",
"docx2txt==0.9",
"python-pptx==1.0.2",
"unstructured==0.18.21",
"unstructured==0.18.24",
"msoffcrypto-tool==5.4.2",
"nltk==3.9.2",
"Markdown==3.10",
@ -91,7 +90,7 @@ dependencies = [
"soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.2",
"pillow==12.0.0",
"pillow==12.1.0",
"opencv-python-headless==4.12.0.88",
"rapidocr-onnxruntime==1.4.4",
"rank-bm25==0.2.2",
@ -139,20 +138,20 @@ postgres = [
all = [
"pymongo",
"psycopg2-binary==2.9.9",
"pgvector==0.4.0",
"psycopg2-binary==2.9.11",
"pgvector==0.4.2",
"moto[s3]>=5.0.26",
"gcp-storage-emulator>=2024.8.3",
"docker~=7.1.0",
"pytest~=8.3.2",
"pytest-docker~=3.2.5",
"playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
"elasticsearch==9.2.0",
"elasticsearch==9.2.1",
"qdrant-client==1.16.2",
"pymilvus==2.6.4",
"weaviate-client==4.19.0",
"pymilvus==2.6.5",
"weaviate-client==4.19.2",
"pymilvus==2.6.6",
"pinecone==6.0.2",
"oracledb==3.4.1",
"colbert-ai==0.2.22",

View file

@ -628,7 +628,7 @@ input[type='number'] {
/* Table styling for tiptap editors */
.tiptap table {
@apply w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full;
@apply w-full text-sm text-start text-gray-500 dark:text-gray-400 max-w-full;
}
.tiptap thead {
@ -641,7 +641,7 @@ input[type='number'] {
}
.tiptap th {
@apply cursor-pointer text-left text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850;
@apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850;
}
.tiptap td {

View file

@ -423,6 +423,19 @@ export const updateUserProfile = async (token: string, profile: object) => {
return res;
};
export const updateUserTimezone = async (token: string, timezone: string) => {
await fetch(`${WEBUI_API_BASE_URL}/auths/update/timezone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({ timezone })
}).catch((err) => {
console.error('Failed to update timezone:', err);
});
};
export const updateUserPassword = async (token: string, password: string, newPassword: string) => {
let error = null;

View file

@ -763,3 +763,155 @@ export const deleteMessage = async (token: string = '', channel_id: string, mess
return res;
};
// Webhook API functions
type WebhookForm = {
name: string;
profile_image_url?: string;
};
export const getChannelWebhooks = async (token: string = '', channel_id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createChannelWebhook = async (
token: string = '',
channel_id: string,
formData: WebhookForm
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateChannelWebhook = async (
token: string = '',
channel_id: string,
webhook_id: string,
formData: WebhookForm
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/update`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteChannelWebhook = async (
token: string = '',
channel_id: string,
webhook_id: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/delete`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -142,6 +142,10 @@ export const getChatList = async (
throw error;
}
if (!res) {
return [];
}
return res.map((chat) => ({
...chat,
time_range: getTimeRange(chat.updated_at)
@ -1166,3 +1170,103 @@ export const archiveAllChats = async (token: string) => {
return res;
};
export const exportChatStats = async (token: string, page: number = 1, params: object = {}) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('page', `${page}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
searchParams.append(key, `${value}`);
}
}
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/stats/export?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const exportSingleChatStats = async (token: string, chatId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/stats/export/${chatId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const downloadChatStats = async (
token: string = '',
updated_at: number | null = null
): Promise<[Response | null, AbortController]> => {
const controller = new AbortController();
let error = null;
let url = `${WEBUI_API_BASE_URL}/chats/stats/export?stream=true`;
if (updated_at) url += `&updated_at=${updated_at}`;
const res = await fetch(url, {
signal: controller.signal,
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
}).catch((err) => {
console.error(err);
error = err;
return null;
});
if (error) {
throw error;
}
return [res, controller];
};

View file

@ -93,6 +93,74 @@ export const getAllFeedbacks = async (token: string = '') => {
return res;
};
export const getLeaderboard = async (token: string = '', query: string = '') => {
let error = null;
const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
const res = await fetch(
`${WEBUI_API_BASE_URL}/evaluations/leaderboard?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getModelHistory = async (token: string = '', modelId: string, days: number = 30) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('days', days.toString());
const res = await fetch(
`${WEBUI_API_BASE_URL}/evaluations/leaderboard/${encodeURIComponent(modelId)}/history?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFeedbackItems = async (token: string = '', orderBy, direction, page) => {
let error = null;

View file

@ -252,7 +252,7 @@ export const getFileContentById = async (id: string) => {
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return await res.blob();
return await res.arrayBuffer();
})
.catch((err) => {
error = err.detail;

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