mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-08 03:08:02 +00:00
Merge branch 'open-webui:dev' into dev
This commit is contained in:
commit
7d84782498
245 changed files with 12870 additions and 2932 deletions
10
.github/pull_request_template.md
vendored
10
.github/pull_request_template.md
vendored
|
|
@ -1,4 +1,4 @@
|
|||
<!--
|
||||
<!--
|
||||
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
|
|
@ -84,13 +84,13 @@ This is to ensure large feature PRs are discussed with the community first, befo
|
|||
|
||||
### Contributor License Agreement
|
||||
|
||||
<!--
|
||||
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||
Keep the "Contributor License Agreement" confirmation text intact.
|
||||
<!--
|
||||
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||
Keep the "Contributor License Agreement" confirmation text intact.
|
||||
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||
-->
|
||||
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
> [!NOTE]
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
|
|
|
|||
150
CHANGELOG.md
150
CHANGELOG.md
|
|
@ -5,6 +5,156 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.8.0] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- 📊 **Analytics dashboard.** Administrators now have access to an Analytics dashboard showing model usage statistics, token consumption by model and user, user activity rankings, and time-series charts with hourly or daily granularity; clicking any model opens a detail view with feedback history, associated tags, and chat browser, and results can be filtered by user group. [#21106](https://github.com/open-webui/open-webui/pull/21106), [Commit](https://github.com/open-webui/open-webui/commit/68a1e87b66a7ec8831d5ed52940c4ef110e3e264), [Commit](https://github.com/open-webui/open-webui/commit/e62649f94044abfed4d7d60647a2050383a67e3d)
|
||||
- 🎯 **Experimental support for Skills.** Open WebUI now supports the Skill standard — allowing users to create and manage reusable AI skills with detailed instructions, reference them in chats using the "$" command, or attach them to specific models for automatic context in conversations. [#21312](https://github.com/open-webui/open-webui/pull/21312)
|
||||
- 🧪 **Experimental support for Open Responses protocol.** Connections can now be configured to use the experimental Open Responses protocol instead of Chat Completions, enabling native support for extended thinking, streaming reasoning tokens, and richer tool call handling for compatible providers. [Commit](https://github.com/open-webui/open-webui/commit/d2c695eb11ddca9fc93499bb0c3fcafcff7099b5), [Commit](https://github.com/open-webui/open-webui/commit/90a057f4005c000bda6ff8703e13e529190af73a), [Commit](https://github.com/open-webui/open-webui/commit/0dc74a8a2e7adb76fb503ef0cd3c02daddd2f4bb), [Commit](https://github.com/open-webui/open-webui/commit/ea9c58ea80646cef05e06d0beaf5e81cc2f78cb1), [Commit](https://github.com/open-webui/open-webui/commit/6ffce4bccdc13b8b61a8b286e34094c981932eda), [Commit](https://github.com/open-webui/open-webui/commit/6719558150920f570d8febe021da65903e53c976), [Commit](https://github.com/open-webui/open-webui/commit/117c091b95a1b1a76a31c31b97304bac289d6f18), [Commit](https://github.com/open-webui/open-webui/commit/aa8c2959ca8476f269786e1317fb6d2938abd3f9), [Commit](https://github.com/open-webui/open-webui/commit/e2d09ac36174de48a7d85bafc8d3291c9ffe44cd)
|
||||
- 👥 **Redesigned access control UI.** The access control UI was redesigned with a more intuitive interface that makes it easier to add multiple groups at once. [#21277](https://github.com/open-webui/open-webui/pull/21277)
|
||||
- 👤 **Per-user resource sharing.** Resources including knowledge bases, prompts, models, tools, channels, and base models can now be shared directly to individual users alongside the existing per-group sharing capability. [#21277](https://github.com/open-webui/open-webui/pull/21277)
|
||||
- 📨 **Message queuing.** Messages can now be queued while a response is generating rather than being blocked, allowing you to continue your train of thought; queued messages are automatically combined and sent when generation completes, and can be edited, deleted, or sent immediately from the input area. [Commit](https://github.com/open-webui/open-webui/commit/62750b8980ef0a3f2da7bc64b5416706a7495686), [Commit](https://github.com/open-webui/open-webui/commit/d3f2cf74748db42311ca04a56ccd1ea15399eca0)
|
||||
- 💡 **Active task sidebar indicator.** Users can now see which chats have active tasks running directly in the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/48522271586a5bf24b649610f03b4ffd8afb2782)
|
||||
- 📝 **Prompt version control.** Prompts now include version control with full history tracking, allowing users to commit changes with messages, view past versions, compare differences between versions, and roll back to previous versions when needed. [#20945](https://github.com/open-webui/open-webui/pull/20945)
|
||||
- 🏷️ **Prompt tags.** Prompts can now be organized with tags, and users can filter the prompt workspace by tag to quickly find related prompts across large collections. [#20945](https://github.com/open-webui/open-webui/pull/20945)
|
||||
- 🐍 **Native function calling code execution.** Code execution now works with Native function calling mode, allowing models to autonomously run Python code for calculations, data analysis, and visualizations without requiring Default mode. [#20592](https://github.com/open-webui/open-webui/pull/20592), [Docs:#998](https://github.com/open-webui/docs/pull/998)
|
||||
- 🚀 **Async web search.** Web search operations now run asynchronously in the background, allowing users to continue interacting with the application while searches complete. [#20630](https://github.com/open-webui/open-webui/pull/20630)
|
||||
- ⚡ **Search debouncing.** Search operations across the application now respond more efficiently with debouncing that reduces unnecessary server requests while typing, improving responsiveness when searching users, groups, functions, tools, prompts, knowledge bases, notes, and when using the knowledge and prompts commands in chat. [#20982](https://github.com/open-webui/open-webui/pull/20982), [Commit](https://github.com/open-webui/open-webui/commit/36766f157d46102fd76c526b42579400ca70de50), [Commit](https://github.com/open-webui/open-webui/commit/fa859de460376782bd0fa35512c8426c9cd0462c), [Commit](https://github.com/open-webui/open-webui/commit/57ec2aa088ffd5a8c3553c53d39799497ff70479)
|
||||
- 🤝 **Shared chats management.** Users can now view and manage all their shared chats from Settings, with options to copy share links or unshare conversations they no longer want public. [Commit](https://github.com/open-webui/open-webui/commit/a10ac774ab5d47b505e840b029c0c0340002508b)
|
||||
- 📁 **User file management.** Users can now view, search, and delete all their uploaded files from Settings, providing centralized file management in one place. [Commit](https://github.com/open-webui/open-webui/commit/93ed4ae2cda2f4311143e51f586aaa73b83a37a7), [#21047](https://github.com/open-webui/open-webui/pull/21047)
|
||||
- 🗑️ **Shift-click quick delete.** Files in the File Manager can now be quickly deleted by holding Shift and clicking the delete button, bypassing the confirmation dialog for faster bulk cleanup. [#21044](https://github.com/open-webui/open-webui/pull/21044)
|
||||
- ⌨️ **Model selector shortcut.** The model selector can now be opened with Ctrl+Shift+M keyboard shortcut. [#21130](https://github.com/open-webui/open-webui/pull/21130)
|
||||
- 🧠 **Smarter knowledge vs web search.** Models now choose more intelligently between knowledge base search and web search rather than always trying knowledge first. [#21115](https://github.com/open-webui/open-webui/pull/21115)
|
||||
- 🌍 **Community model reviews.** Users can now access community reviews for models directly from the model selector menu and are prompted to leave reviews after rating responses, with administrators able to disable this via the "Community Sharing" setting. [Commit](https://github.com/open-webui/open-webui/commit/bc90463ea60c9a66accb1fd242cf1853910ca838)
|
||||
- 📄 **Prompts workspace pagination.** The prompts workspace now includes pagination for large prompt collections, loading 30 prompts at a time with search, filtering, and sorting capabilities for improved performance and navigation. [Commit](https://github.com/open-webui/open-webui/commit/36766f157d46102fd76c526b42579400ca70de50)
|
||||
- 🎨 **Action function HTML rendering.** Action functions can now render rich HTML content directly in chat as embedded iframes, matching the capabilities that tools already had and eliminating the need for action authors to inject codeblocks. [#21294](https://github.com/open-webui/open-webui/pull/21294), [Commit](https://github.com/open-webui/open-webui/commit/60ada21c152ed642971429fdbe88dcbf478cf83a)
|
||||
- 🔒 **Password-masked valve fields.** Tool and function developers can now mark sensitive fields as passwords, which are automatically masked in the settings UI to prevent shoulder surfing and accidental exposure. [#20852](https://github.com/open-webui/open-webui/issues/20852), [Commit](https://github.com/open-webui/open-webui/commit/8c70453b2e3a6958437d951751e84acbbaafd9aa)
|
||||
- 📋 **Prompt quick copy.** Prompts in the workspace now include a quick copy button for easily copying prompt content to the clipboard. [Commit](https://github.com/open-webui/open-webui/commit/78f856e2049991441a3469230ae52799cb86954e)
|
||||
- 🔔 **Dismissible notification toasts.** Notification toasts for new messages and other events now include a close button that appears on hover, allowing users to dismiss them immediately instead of waiting for auto-dismissal. [#21056](https://github.com/open-webui/open-webui/issues/21056), [Commit](https://github.com/open-webui/open-webui/commit/73bb600034c8532e30726129743a5ffe9002c5fb)
|
||||
- 🔔 **Temporary chat notification privacy.** Notifications from temporary chats now only appear on the device where the chat is running, preventing privacy leaks across logged-in sessions. [#21292](https://github.com/open-webui/open-webui/pull/21292)
|
||||
- 💡 **Null chat title fallback.** Notifications without chat titles now display "New Chat" instead of showing null. [#21292](https://github.com/open-webui/open-webui/pull/21292)
|
||||
- 🖼️ **Concurrent image editing.** Image editing operations with multiple images now complete faster by loading all images concurrently instead of sequentially. [#20911](https://github.com/open-webui/open-webui/pull/20911)
|
||||
- 📧 **USER_EMAIL template variable.** Users can now reference their email address in prompts and system messages using the "{{USER_EMAIL}}" template variable. [#20881](https://github.com/open-webui/open-webui/pull/20881)
|
||||
- 🔤 **Alphabetical tool ordering.** Tools and Functions in the Chat Controls sidebar now appear in alphabetical order, making it easier to locate specific tools when working with multiple integrations. [#20871](https://github.com/open-webui/open-webui/pull/20871)
|
||||
- 👁️ **Model list status filtering.** Administrators can now filter the model list by status (enabled, disabled, visible, hidden) and bulk enable or disable all filtered models at once. [#20553](https://github.com/open-webui/open-webui/issues/20553), [#20774](https://github.com/open-webui/open-webui/issues/20774), [Commit](https://github.com/open-webui/open-webui/commit/96a9696383d450dad2cbb230f3756ebfa258e029)
|
||||
- ⚙️ **Per-model built-in tool toggles.** Administrators can now enable or disable individual built-in tools for each model, including time utilities, memory, chat history, notes, knowledge base, and channels. [#20641](https://github.com/open-webui/open-webui/issues/20641), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c)
|
||||
- 📑 **PDF loading modes.** Administrators can now choose between "page" and "single" PDF loading modes, allowing documents to be processed as individual pages or as complete documents for better chunking across page boundaries. [Commit](https://github.com/open-webui/open-webui/commit/ecbdef732bc71a07c21bbb679edb420f26eac181)
|
||||
- 📑 **Model Settings pagination.** Administrators can now navigate large model lists more efficiently in Model Settings, with pagination displaying 30 models per page for smoother navigation. [Commit](https://github.com/open-webui/open-webui/commit/2f584c9f88aeb34ece07b10d05794020d1d656b8)
|
||||
- 📌 **Pin read-only models.** Users can now pin read-only models from the workspace. [#21308](https://github.com/open-webui/open-webui/issues/21308), [Commit](https://github.com/open-webui/open-webui/commit/97331bf11d41ca54e47f86777fb8dbd73988c631)
|
||||
- 🔍 **Yandex search provider.** Administrators can now configure Yandex as a web search provider, expanding search engine options for retrieval-augmented generation. [#20922](https://github.com/open-webui/open-webui/pull/20922)
|
||||
- 🔐 **Custom password hints.** Administrators can now provide custom password requirement hints to users via the "PASSWORD_VALIDATION_HINT" environment variable, making it clearer what password criteria must be met during signup or password changes. [#20647](https://github.com/open-webui/open-webui/issues/20647), [#20650](https://github.com/open-webui/open-webui/pull/20650)
|
||||
- 🔑 **OAuth token exchange.** Administrators can now enable OAuth token exchange via "ENABLE_OAUTH_TOKEN_EXCHANGE", allowing external applications to authenticate users by exchanging OAuth provider tokens for Open WebUI session tokens. [Commit](https://github.com/open-webui/open-webui/commit/655420fd25ed0ea872954baa485030079c00c10e)
|
||||
- 🗄️ **Weaviate custom endpoints.** Administrators can now connect to self-hosted Weaviate deployments with separate HTTP and gRPC endpoints via new environment variables. [#20620](https://github.com/open-webui/open-webui/pull/20620)
|
||||
- 🛡️ **MCP custom SSL certificates.** Administrators can now connect to MCP servers with self-signed or custom SSL certificates via the "AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL" environment variable. [#20875](https://github.com/open-webui/open-webui/issues/20875), [Commit](https://github.com/open-webui/open-webui/commit/c7f996d593e4bb48103b91316204fe7e50e25b35)
|
||||
- 🗃️ **Redis Sentinel reconnection delay.** Administrators using Redis Sentinel can now configure a reconnection delay via "REDIS_RECONNECT_DELAY" to prevent retry exhaustion during failover elections. [#21021](https://github.com/open-webui/open-webui/pull/21021)
|
||||
- 📡 **Custom user info headers.** Administrators can now customize the header names used when forwarding user information to external services, enabling compatibility with services like AWS Bedrock AgentCore that require specific header prefixes. [Commit](https://github.com/open-webui/open-webui/commit/6c0f886cdf4b4249dca29e9340b3b998a7262d61)
|
||||
- 🔗 **Forward user info to tool servers.** User identity and chat context can now be forwarded to MCP servers and external tool servers when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled, allowing tool providers to implement per-user authorization, auditing, and rate limiting. [#21092](https://github.com/open-webui/open-webui/pull/21092), [Commit](https://github.com/open-webui/open-webui/commit/2c37daef86a058e370151ecead17f10078102307)
|
||||
- 📬 **External tool event emitters.** External tools (OpenAPI/MCP) can now send tool events back to Open WebUI using the event emitter endpoint, as message ID is now forwarded alongside chat ID when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled. [#21214](https://github.com/open-webui/open-webui/pull/21214)
|
||||
- 📥 **Playground chat export.** Administrators can now export playground chats as JSON or plain text files, allowing them to save their conversations for backup or sharing outside the platform. [Commit](https://github.com/open-webui/open-webui/commit/8e2b0b6fd2ac99c833a110e2bc6aa655f1682669)
|
||||
- 🖼️ **Images playground.** Administrators can now test image generation and editing directly in a new Images playground, with support for uploading source images for edits and downloading results. [Commit](https://github.com/open-webui/open-webui/commit/94302de49b27bdf1df86b5c26f2cafb98f964e52)
|
||||
- 🛠️ **Dynamic dropdown valve fields.** Tool and function developers can now create dropdown fields with dynamically-generated options that update based on runtime context, such as available models or user permissions. [Commit](https://github.com/open-webui/open-webui/commit/474427c67e953bb9f7d122757a756a639214e0b2)
|
||||
- 🏎️ **Faster profile updates.** User profile updates and role changes are now faster by eliminating redundant database queries. [#21011](https://github.com/open-webui/open-webui/pull/21011)
|
||||
- 🔑 **Faster authentication.** User authentication is now 34% faster by combining database lookups into a single query. [#21010](https://github.com/open-webui/open-webui/pull/21010)
|
||||
- 🔋 **Faster chat completions.** Chat completions and embeddings now respond much faster by checking the model cache before fetching model lists, reducing Time To First Token from several seconds to subsecond for most requests. [#20886](https://github.com/open-webui/open-webui/pull/20886), [#20069](https://github.com/open-webui/open-webui/discussions/20069)
|
||||
- 🏎️ **Faster Redis model list loading.** Model list loading is now significantly faster when using Redis with many models, reducing API response latency by caching configuration values locally instead of making repeated Redis lookups on every model iteration. [#21306](https://github.com/open-webui/open-webui/pull/21306)
|
||||
- 💨 **Faster knowledge base file batch-add.** Batch-adding files to knowledge bases is now faster with a single database query instead of one query per file. [#21006](https://github.com/open-webui/open-webui/pull/21006)
|
||||
- ⚡ **Smoother model selector dropdown.** The model selector dropdown now renders smoothly even with hundreds of models, eliminating the lag and freezing that occurred when opening the dropdown with large model lists. [Commit](https://github.com/open-webui/open-webui/commit/4331029926245b7b74fa8e254610c91400b239b0)
|
||||
- 🚗 **Faster model visibility toggling.** Toggling model visibility in the admin panel is now faster with optimized database access. [#21009](https://github.com/open-webui/open-webui/pull/21009)
|
||||
- 💾 **Faster model access control checks.** Model access control checks are now faster by batch-fetching model info and group memberships upfront instead of querying for each model. [#21008](https://github.com/open-webui/open-webui/pull/21008)
|
||||
- ⚙️ **Faster model list and imports.** Model list loading and model imports are now faster by eliminating redundant database queries. [#21004](https://github.com/open-webui/open-webui/pull/21004)
|
||||
- 🏃 **Faster SCIM group member lookups.** SCIM group member lookups are now up to 13x faster by batching user queries instead of fetching each member individually. [#21005](https://github.com/open-webui/open-webui/pull/21005)
|
||||
- 💨 **Batched group member counts.** Group member counts are now fetched in a single batch query when loading group lists, eliminating redundant database lookups. [Commit](https://github.com/open-webui/open-webui/commit/96c07f44a8f5e6346b2ea6ac529ff4ec3c47e90a)
|
||||
- 💨 **Faster bulk operations.** Bulk feedback deletion and group member removal are now 4-5x faster with optimized batch operations. [#21019](https://github.com/open-webui/open-webui/pull/21019)
|
||||
- 🧠 **Faster memory updates.** Memory updates are now up to 39% faster by eliminating redundant database queries. [#21013](https://github.com/open-webui/open-webui/pull/21013)
|
||||
- ⚙️ **Faster filter function loading.** Filter function loading is now faster by batching database queries instead of fetching each function individually. [#21018](https://github.com/open-webui/open-webui/pull/21018)
|
||||
- 🖼️ **Image model regex configuration.** Administrators can now configure which image generation models support auto-sizing and URL responses via new regex environment variables, improving compatibility with LiteLLM and other proxies that use prefixed model names. [#21126](https://github.com/open-webui/open-webui/pull/21126), [Commit](https://github.com/open-webui/open-webui/commit/ecf3fa2feb28e74ff6c17ca97d94581f316da56a)
|
||||
- 🎁 **Easter eggs toggle.** Administrators can now control the visibility of easter egg features via the "ENABLE_EASTER_EGGS" environment variable. [Commit](https://github.com/open-webui/open-webui/commit/907dba4517903e5646e40223a0edca26a7107bc8)
|
||||
- 🔌 **Independent access control updates.** API endpoints now support independent access control updates for models, tools, knowledge bases, and notes, enabling finer-grained permission management. [Commit](https://github.com/open-webui/open-webui/commit/0044902c082f8475336cc7d5c57fe3f35ab0555d), [Commit](https://github.com/open-webui/open-webui/commit/c259c878060af1b03b702c943e8813d7b4fc3199), [Commit](https://github.com/open-webui/open-webui/commit/e3a825769063cee486650cc2eb9a032676e630c5)
|
||||
- ♿ **Screen reader accessibility.** Screen reader users now hear the password field label only once on the login page, improving form navigation for assistive technology users. [Commit](https://github.com/open-webui/open-webui/commit/1441d0d735c7a1470070b33327e1dd4dc5ca1131)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Catalan, Finnish, Irish, French, German, Japanese, Latvian, Polish, Portuguese (Brazil), Simplified Chinese, Slovak, Spanish, and Traditional Chinese were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚡ **Connection pool exhaustion fix.** Database connection pool exhaustion and timeout errors during concurrent usage have been resolved by releasing connections before chat completion requests and embedding operations for memory and knowledge base processing. [#20569](https://github.com/open-webui/open-webui/pull/20569), [#20570](https://github.com/open-webui/open-webui/pull/20570), [#20571](https://github.com/open-webui/open-webui/pull/20571), [#20572](https://github.com/open-webui/open-webui/pull/20572), [#20573](https://github.com/open-webui/open-webui/pull/20573), [#20574](https://github.com/open-webui/open-webui/pull/20574), [#20575](https://github.com/open-webui/open-webui/pull/20575), [#20576](https://github.com/open-webui/open-webui/pull/20576), [#20577](https://github.com/open-webui/open-webui/pull/20577), [#20578](https://github.com/open-webui/open-webui/pull/20578), [#20579](https://github.com/open-webui/open-webui/pull/20579), [#20580](https://github.com/open-webui/open-webui/pull/20580), [#20581](https://github.com/open-webui/open-webui/pull/20581), [Commit](https://github.com/open-webui/open-webui/commit/7da37b4f66b9b2e821796b06b75e03cb0237e0a9), [Commit](https://github.com/open-webui/open-webui/commit/9af40624c5f0f8f7f640a11356e167543b07b2bb)
|
||||
- 🚫 **LDAP authentication hang fix.** LDAP authentication no longer freezes the entire service when logging in with non-existent accounts, preventing application hangs. [Commit](https://github.com/open-webui/open-webui/commit/a4281f6a7fbc9764b57830e4ef81bb780aa34af9), [#21300](https://github.com/open-webui/open-webui/issues/21300)
|
||||
- ✅ **Trusted Header auto-registration fix.** Trusted Header Authentication now properly auto-registers new users after the first login, assigning the configured default role instead of failing for users not yet in the database. [Commit](https://github.com/open-webui/open-webui/commit/9b30e8f6894c8c8bad0a9ce4693eab810962adc9)
|
||||
- 🛡️ **SSRF protection for image loading.** External image loading now validates URLs before fetching to prevent SSRF attacks against local and private network addresses. [Commit](https://github.com/open-webui/open-webui/commit/ce50d9bac4f30b054b09a2fbda52569b73ea591c)
|
||||
- 🛡️ **Malformed Authorization header fix.** Malformed Authorization headers no longer cause server crashes; requests are now handled gracefully instead of returning HTTP 500 errors. [#20938](https://github.com/open-webui/open-webui/issues/20938), [Commit](https://github.com/open-webui/open-webui/commit/7e79f8d1c6b5a02f1a46e792540c6bbf7bed8edc)
|
||||
- 🚪 **Channel notification access control.** Users without channel permissions can no longer access channels through notifications, properly enforcing access controls across all channel entry points. [#20883](https://github.com/open-webui/open-webui/pull/20883), [#20789](https://github.com/open-webui/open-webui/discussions/20789)
|
||||
- 🐛 **Ollama model name suffix fix.** Ollama-compatible providers that do not use ":latest" in model names can now successfully chat, fixing errors where model names were incorrectly appended with ":latest" suffixes. [#21331](https://github.com/open-webui/open-webui/issues/21331), [Commit](https://github.com/open-webui/open-webui/commit/05ae44b98dc279ee12cc8eab17278ccbfec60301)
|
||||
- ♻️ **Streaming connection cleanup.** Streaming responses now properly clean up network connections when interrupted, preventing "Unclosed client session" errors from accumulating over time. [#20889](https://github.com/open-webui/open-webui/pull/20889), [#17058](https://github.com/open-webui/open-webui/issues/17058)
|
||||
- 💾 **Inline image context exhaustion fix.** Inline images no longer exhaust the model's context window by including their full base64 data in chat metadata, preventing premature context exhaustion with image-heavy conversations. [#20916](https://github.com/open-webui/open-webui/pull/20916)
|
||||
- 🚀 **Status indicator GPU usage fix.** High GPU usage caused by the user online status indicator animation has been resolved, reducing consumption from 35-40% to near-zero in browsers with hardware acceleration. [#21062](https://github.com/open-webui/open-webui/issues/21062), [Commit](https://github.com/open-webui/open-webui/commit/938d1b0743c64f0ce513d68e57dfbb86987cb06b)
|
||||
- 🔧 **Async pipeline operations.** Pipeline operations now run asynchronously instead of blocking the FastAPI event loop, allowing the server to handle other requests while waiting for external pipeline API calls. [#20910](https://github.com/open-webui/open-webui/pull/20910)
|
||||
- 🔌 **MCP tools regression fix.** MCP tools now work reliably again after a regression in v0.7.2 that caused "cannot pickle '\_asyncio.Future' object" errors when attempting to use MCP servers in chat. [#20629](https://github.com/open-webui/open-webui/issues/20629), [#20500](https://github.com/open-webui/open-webui/issues/20500), [Commit](https://github.com/open-webui/open-webui/commit/886c12c5664bc2dd73313330f61c2257169da6d1)
|
||||
- 🔗 **Function chat ID propagation fix.** Functions now reliably receive the chat identifier during internal task invocations like web search query generation, RAG query generation, and image prompt generation, enabling stateful functions to maintain consistent per-chat state without fragmentation. [#20563](https://github.com/open-webui/open-webui/issues/20563), [#20585](https://github.com/open-webui/open-webui/pull/20585)
|
||||
- 💻 **Markdown fence code execution fix.** Code execution now works reliably when models wrap code in markdown fences, automatically stripping the backticks before execution to prevent syntax errors that affected most non-GPT models. [#20941](https://github.com/open-webui/open-webui/issues/20941), [Commit](https://github.com/open-webui/open-webui/commit/4a5516775927aaf002212f2e09c55a17c699bc46), [Commit](https://github.com/open-webui/open-webui/commit/683438b418fb3b453a8ad88c1ba1a9944eac3593)
|
||||
- 💻 **ANSI code execution fix.** Code execution is now reliable when LLMs include ANSI terminal color codes in their output, preventing random failures that previously caused syntax errors. [#21091](https://github.com/open-webui/open-webui/issues/21091), [Commit](https://github.com/open-webui/open-webui/commit/b1737040a7d3bb5efcfe0f1432e89d7e82e51d2d)
|
||||
- 🗨️ **Incomplete model metadata crash fix.** Starting chats with models that have incomplete metadata information no longer crashes the application. [#20565](https://github.com/open-webui/open-webui/issues/20565), [Commit](https://github.com/open-webui/open-webui/commit/14f6747dfc66fb7e942b930650286012121e5262)
|
||||
- 💬 **Unavailable model crash fix.** Adding message pairs with Ctrl+Shift+Enter no longer crashes when the chat's model is unavailable, showing a helpful error message instead. [#20663](https://github.com/open-webui/open-webui/pull/20663)
|
||||
- 📚 **Knowledge base file upload fix.** Uploading files to knowledge bases now works correctly, fixing database mapping errors that prevented file uploads. [#20925](https://github.com/open-webui/open-webui/issues/20925), [#20931](https://github.com/open-webui/open-webui/pull/20931)
|
||||
- 🧠 **Knowledge base query type fix.** Knowledge base queries no longer fail intermittently when models send tool call parameters as strings instead of their expected types. [#20705](https://github.com/open-webui/open-webui/pull/20705)
|
||||
- 📚 **Knowledge base reindex fix.** Reindexing knowledge base files now works correctly instead of failing with duplicate content errors. [#20854](https://github.com/open-webui/open-webui/issues/20854), [#20857](https://github.com/open-webui/open-webui/pull/20857)
|
||||
- 🔧 **Multi-worker knowledge base timeout fix.** In multi-worker deployments, uploading very large documents to knowledge bases no longer causes workers to be killed by health check timeouts, and administrators can now configure a custom embedding timeout via "RAG_EMBEDDING_TIMEOUT". [#21158](https://github.com/open-webui/open-webui/pull/21158), [Discussion](https://github.com/open-webui/open-webui/discussions/21151), [Commit](https://github.com/open-webui/open-webui/commit/c653e4ec54d070aee5e9568d016daebb61f06632)
|
||||
- 🌅 **Dark mode icon inversion fix.** Icons in chat and action menus are now displayed correctly in dark mode, fixing an issue where PNG icons with "svg" in their base64 encoding were randomly inverted. [#21272](https://github.com/open-webui/open-webui/pull/21272), [Commit](https://github.com/open-webui/open-webui/commit/0a44d80252afae73de4098ab1c3eb6cf54157fd6)
|
||||
- 🛠️ **Admin model write permission fix.** Fixed the admin panel allowing models to be assigned write permissions, since users with write permission are not admins and cannot write. [Commit](https://github.com/open-webui/open-webui/commit/4aedfdc5471a1f13c1084b34b48ea3ed6311cd42)
|
||||
- 🛠️ **Prompt access control save fix.** Prompt access control settings are now saved correctly when modifying resource permissions. [Commit](https://github.com/open-webui/open-webui/commit/30f72672fac2579c267a076e6ba89dfe1812137b)
|
||||
- ✏️ **Knowledge base file edit fix.** Editing files within knowledge bases now saves correctly and can be used for retrieval, fixing a silent failure where the save appeared successful but the file could not be searched. [Commit](https://github.com/open-webui/open-webui/commit/f9ab66f51a52388a4eb084c8f69044e79bf5cb04)
|
||||
- 🖼️ **Reasoning section artifact rendering fix.** Code blocks within model reasoning sections no longer incorrectly render as interactive artifacts, ensuring only intended output displays as previews. [#20801](https://github.com/open-webui/open-webui/issues/20801), [#20877](https://github.com/open-webui/open-webui/pull/20877), [Commit](https://github.com/open-webui/open-webui/commit/4c6f100b5fe2145a3d676b70b5f7c0e7f07cee20)
|
||||
- 🔐 **Group resource sharing fix.** Sharing resources with groups now works correctly, fixing database errors and an issue where models shared with read-only access were not visible to group members. [#20666](https://github.com/open-webui/open-webui/issues/20666), [#21043](https://github.com/open-webui/open-webui/issues/21043), [Commit](https://github.com/open-webui/open-webui/commit/5a075a2c836e46b83f8710285f09aff1f6125072)
|
||||
- 🔑 **Docling API key fix.** Docling API key authentication now works correctly by using the proper "X-Api-Key" header format instead of the incorrect "Bearer" authorization prefix. [#20652](https://github.com/open-webui/open-webui/pull/20652)
|
||||
- 🔌 **MCP OAuth 2.1 fix.** MCP OAuth 2.1 authentication now works correctly, resolving connection verification failures and 401 errors during the authorization callback. [#20808](https://github.com/open-webui/open-webui/issues/20808), [#20828](https://github.com/open-webui/open-webui/issues/20828), [Commit](https://github.com/open-webui/open-webui/commit/8eebc2aea63b7045e61c9689a65a2dfa9c797bcb)
|
||||
- 💻 **MATLAB syntax highlighting.** MATLAB code blocks now display with proper syntax highlighting in chat messages. [#20719](https://github.com/open-webui/open-webui/issues/20719), [#20773](https://github.com/open-webui/open-webui/pull/20773)
|
||||
- 📊 **CSV export HTML entity decoding.** Exporting tables to CSV now properly decodes HTML entities, ensuring special characters display correctly in the exported file. [#20688](https://github.com/open-webui/open-webui/pull/20688)
|
||||
- 📄 **Markdown Header Text Splitter persistence.** The "Markdown Header Text Splitter" document setting now persists correctly when disabled, preventing it from reverting to enabled after page refresh. [#20929](https://github.com/open-webui/open-webui/issues/20929), [#20930](https://github.com/open-webui/open-webui/pull/20930)
|
||||
- 🔌 **Audio service timeout handling.** Audio transcription and text-to-speech requests now have proper timeouts, preventing the UI from freezing when external services don't respond. [#21055](https://github.com/open-webui/open-webui/pull/21055)
|
||||
- 💬 **Reference Chats visibility fix.** The "Reference Chats" option now appears in the message input menu even when the sidebar is collapsed, fixing the issue where it was hidden on mobile devices and at first load. [#20827](https://github.com/open-webui/open-webui/issues/20827), [Commit](https://github.com/open-webui/open-webui/commit/a3600e8b219fc4c019b95258d16bd3e2827490c6)
|
||||
- 🔍 **Chat search self-exclusion.** The "search_chats" builtin tool now excludes the current conversation from search results, preventing redundant matches. [#20718](https://github.com/open-webui/open-webui/issues/20718), [Commit](https://github.com/open-webui/open-webui/commit/1a4bdd2b30017d901b9cac1e2e10684ec1edd062)
|
||||
- 📚 **Knowledge base pagination fix.** Paginating through knowledge base files no longer shows duplicates or skips files when multiple documents share the same update timestamp. [#20846](https://github.com/open-webui/open-webui/issues/20846), [Commit](https://github.com/open-webui/open-webui/commit/a9a0ce6beaa286cc18eff24b518a6f3d7a560e2f)
|
||||
- 📋 **Batch file error reporting.** Batch file processing operations now return properly structured error information when failures occur, making it clearer what went wrong during multi-file operations. [#20795](https://github.com/open-webui/open-webui/issues/20795), [Commit](https://github.com/open-webui/open-webui/commit/68b2872ed645cffb641fa5a21a784d6e9ea0d72b)
|
||||
- ⚙️ **Persistent config with Redis fix.** Configuration values now respect the "ENABLE_PERSISTENT_CONFIG" setting when Redis is used, ensuring environment variables are reloaded on restart when persistent config is disabled. [#20830](https://github.com/open-webui/open-webui/issues/20830), [Commit](https://github.com/open-webui/open-webui/commit/5d48e48e15b003874cc821d896998a01e87580a0)
|
||||
- 🔧 **Engine.IO logging fix.** The "WEBSOCKET_SERVER_ENGINEIO_LOGGING" environment variable now works correctly, allowing administrators to configure Engine.IO logging independently from general websocket logging. [#20727](https://github.com/open-webui/open-webui/pull/20727), [Commit](https://github.com/open-webui/open-webui/commit/5cfb7a08cbde5d39aaf4097b849a80da87c30d66)
|
||||
- 🌐 **French language default fix.** Browsers requesting French language now default to French (France) instead of French (Canada), matching standard language preference expectations. [#20603](https://github.com/open-webui/open-webui/pull/20603), [Commit](https://github.com/open-webui/open-webui/commit/4d9a7cc6c0adea54b58046c576250a0c3ae7b512)
|
||||
- 🔘 **Firefox delete button fix.** Pressing Enter after clicking delete buttons no longer incorrectly retriggers confirmation modals in Firefox. [Commit](https://github.com/open-webui/open-webui/commit/57a2024c58b9c674f2ae08eeb552994ef1796888)
|
||||
- 🌍 **RTL table rendering fix.** Chat markdown tables now correctly display right-to-left when containing RTL language content (Arabic, Hebrew, Farsi, etc.), matching the "Auto" direction setting behavior. [#21160](https://github.com/open-webui/open-webui/issues/21160), [Commit](https://github.com/open-webui/open-webui/commit/284b97bd84c824013ad00ea07621192ec69a5e93)
|
||||
- 🔒 **Write permission enforcement for tools.** Users without write permissions are now properly prevented from editing tools, with a clear error message displayed when attempting unauthorized edits. [Commit](https://github.com/open-webui/open-webui/commit/85e92fe3b062ae669985c09495f6ff1baf8176ab), [Commit](https://github.com/open-webui/open-webui/commit/91faa9fd5a1cfc5d3ab531d2d91d28db52bcc702)
|
||||
- 🛡️ **Chat Valves permission enforcement.** The "Allow Chat Valves" permission is now properly enforced in the integrations menu, preventing users from bypassing access restrictions. [#20691](https://github.com/open-webui/open-webui/pull/20691)
|
||||
- 📝 **Audit log browser session fix.** Audit logs now properly capture all user activity including browser-based sessions, not just API key requests. [#20651](https://github.com/open-webui/open-webui/issues/20651), [Commit](https://github.com/open-webui/open-webui/commit/86e6b2b68b85e958188881785495030de1a30402), [Commit](https://github.com/open-webui/open-webui/commit/ee5fd1246cb3f8f16ca5cbb24feeea43b7800dcb)
|
||||
- 🎨 **Long model name truncation.** Long model names and IDs in the admin panel now truncate properly to prevent visual overflow, with full names visible on hover. [#20696](https://github.com/open-webui/open-webui/pull/20696)
|
||||
- 👥 **Admin user filter pagination fix.** Filtering users in the admin panel now automatically resets to page 1, preventing empty results when searching from pages beyond the first. [#20723](https://github.com/open-webui/open-webui/pull/20723), [Commit](https://github.com/open-webui/open-webui/commit/be75bc506adb048ef11b1612c0e3662511c920d0)
|
||||
- 🔎 **Username search on workspace pages.** Searching for users by username now works correctly on Models, Knowledge, and Functions workspace pages, making it easier to find resources owned by specific users. [#20780](https://github.com/open-webui/open-webui/pull/20780)
|
||||
- 🗑️ **File deletion orphaned embeddings fix.** Deleting files now properly removes associated knowledge base embeddings, preventing orphaned data from accumulating. [Commit](https://github.com/open-webui/open-webui/commit/93ed4ae2cda2f4311143e51f586aaa73b83a37a7)
|
||||
- 🧹 **Event listener memory leak fix.** Memory leaks caused by event listeners not being cleaned up during navigation have been resolved. [#20913](https://github.com/open-webui/open-webui/pull/20913)
|
||||
- 🐳 **Docker Ollama update fix.** Ollama can now be updated within Docker containers after adding a missing zstd dependency. [#20994](https://github.com/open-webui/open-webui/issues/20994), [#21052](https://github.com/open-webui/open-webui/pull/21052)
|
||||
- 📝 **Workspace duplicate API request fix.** The prompts, knowledge, and models workspaces no longer make duplicate API requests when loading. [Commit](https://github.com/open-webui/open-webui/commit/ab5dfbda54664c9278b0d807ba06cad94edd798f), [Commit](https://github.com/open-webui/open-webui/commit/e5dbfc420dd3e7f6ba047a3e11584449ff0742b4)
|
||||
- 📡 **OpenTelemetry Redis cluster fix.** OpenTelemetry instrumentation now works correctly with Redis cluster mode deployments. [#21129](https://github.com/open-webui/open-webui/pull/21129)
|
||||
- 🐳 **Airgapped NLTK tokenizer fix.** Document extraction now works reliably in airgapped environments after container restarts by bundling NLTK tokenizer data in the Docker image. [#21165](https://github.com/open-webui/open-webui/pull/21165), [#21150](https://github.com/open-webui/open-webui/issues/21150)
|
||||
- 💬 **Channel model mention crash fix.** Mentioning a model in channels no longer crashes when older thread messages have missing data. [#21112](https://github.com/open-webui/open-webui/pull/21112)
|
||||
- 🔧 **OpenAPI tool import fix.** Importing OpenAPI tool specifications no longer crashes when parameters lack explicit name fields, fixing compatibility with complex request body definitions. [#21121](https://github.com/open-webui/open-webui/pull/21121), [Commit](https://github.com/open-webui/open-webui/commit/8e79b3d0bc4903f30e747b663ac818976618c83c)
|
||||
- 🌐 **Webpage attachment content fix.** Attaching webpages to chats now retrieves full content instead of only metadata, fixing an unawaited coroutine in SSL certificate verification. [#21166](https://github.com/open-webui/open-webui/issues/21166), [Commit](https://github.com/open-webui/open-webui/commit/a214ec40ea00eebcba49570647ca6ab8f61765d5)
|
||||
- 💾 **File upload settings persistence.** File upload settings (Max Upload Size, Max File Count, Image Compression dimensions) now persist correctly and are no longer erased when updating other RAG configuration settings. [#21057](https://github.com/open-webui/open-webui/issues/21057), [Commit](https://github.com/open-webui/open-webui/commit/258454276e1ef8ded24968515f7bf5e1833ca011)
|
||||
- 📦 **Tool call expand/collapse fix.** Tool call results in chat can now be expanded and collapsed again after a recent refactor disabled this behavior. [#21205](https://github.com/open-webui/open-webui/pull/21205)
|
||||
- 🪛 **Disabled API endpoint bypass fix.** Fixed Ollama/OpenAI API endpoints bypassing 'ENABLE_OLLAMA_API' and 'ENABLE_OPENAI_API' flags when the 'url_idx' parameter was provided. Endpoints now properly return a 503 error with a clear "API is disabled" message instead of attempting to connect and logging confusing connection errors.
|
||||
- 🛠️ **OpenSearch 3.0 compatibility fix.** Document uploads to knowledge bases now work correctly when using OpenSearch backend with opensearch-py >= 3.0.0, fixing a TypeError that previously caused failures. [#21248](https://github.com/open-webui/open-webui/pull/21248), [#20649](https://github.com/open-webui/open-webui/issues/20649)
|
||||
- 📱 **Gboard multi-line paste fix.** Multi-line text pasted from Gboard on Android now inserts correctly instead of being replaced with a single newline, fixing a bug where the keyboard's clipboard suggestion strip sent text via 'insertText' events instead of standard paste events. [#21265](https://github.com/open-webui/open-webui/pull/21265)
|
||||
- 🔧 **Batch embeddings endpoint fix.** The '/api/embeddings' endpoint now correctly returns separate embeddings for each input string when processing batch requests to Ollama providers. [Commit](https://github.com/open-webui/open-webui/commit/8fd5c06e5bf7e0ccbda15d83338912ea17f66783), [#21279](https://github.com/open-webui/open-webui/issues/21279)
|
||||
- 🗝️ **SSL verification for embeddings.** SSL certificate verification now respects the "AIOHTTP_CLIENT_SESSION_SSL" setting for OpenAI and Azure OpenAI embedding requests, allowing connections to self-signed certificate endpoints when disabled. [Commit](https://github.com/open-webui/open-webui/commit/cd31b8301b38bfa86872608cfbd022ff74e3ae52)
|
||||
- 🔧 **Tool call HTML entity fix.** Models now receive properly formatted tool call results in multi-turn conversations, fixing an issue where HTML entities caused malformed content that was hard to parse. [#20755](https://github.com/open-webui/open-webui/pull/20755)
|
||||
- 💾 **Duplicate inline image context fix.** Inline images no longer exhaust the model's context window by including their full base64 data in chat metadata, preventing premature context exhaustion with image-heavy conversations. [#20916](https://github.com/open-webui/open-webui/pull/20916)
|
||||
- 🐛 **OpenAI model cache lookup fix.** The OpenAI API router model lookup was corrected to use the proper model identifier when checking the cache, ensuring consistent and correct model retrieval during chat completions. [#21327](https://github.com/open-webui/open-webui/pull/21327)
|
||||
- 🐛 **Ollama latest suffix fix.** Ollama-compatible providers that don't use ":latest" in model names can now successfully chat, fixing errors where model names were incorrectly appended with ":latest" suffixes. [#21331](https://github.com/open-webui/open-webui/issues/21331), [Commit](https://github.com/open-webui/open-webui/commit/05ae44b98dc279ee12cc8eab17278ccbfec60301)
|
||||
- ⛔ **OpenAI endpoint detection fix.** OpenAI API endpoint detection was corrected to use exact hostname matching instead of substring matching, preventing third-party providers with similar URL patterns from being incorrectly filtered. [Commit](https://github.com/open-webui/open-webui/commit/423d8b18170a0b92b582aba6ef7bb9ba173e876e)
|
||||
- 🛠️ **RedisCluster task stopping fix.** Task stopping now works correctly in RedisCluster deployments, fixing an issue where tasks would remain active after cancellation attempts. [#20803](https://github.com/open-webui/open-webui/pull/20803), [Commit](https://github.com/open-webui/open-webui/commit/0dcbd05e2436929ae9d2c559a204844ae0239b57)
|
||||
- 📎 **Citation parsing error fix.** Citation parsing no longer crashes when builtin tools return error responses, fixing AttributeError issues when tools like search_web fail. [#21071](https://github.com/open-webui/open-webui/pull/21071)
|
||||
|
||||
### Changed
|
||||
|
||||
- ‼️ **Database Migration Required** — This release includes database schema changes; multi-worker, multi-server, or load-balanced deployments must update all instances simultaneously rather than performing rolling updates, as running mixed versions will cause application failures due to schema incompatibility between old and new instances.
|
||||
- ⚠️ **Chat Message Table Migration** — This release includes a new chat message table migration that can take a significant amount of time to complete in larger deployments with extensive chat histories. Administrators should plan for adequate maintenance windows and allow the migration to complete fully without interruption. Running the migration with insufficient time or resources may result in data integrity issues.
|
||||
- 🔗 **Prompt ID-based URLs.** Prompts now use unique ID-based URLs instead of command-based URLs, allowing more flexible command renaming without breaking saved links or integrations. [#20945](https://github.com/open-webui/open-webui/pull/20945)
|
||||
|
||||
## [0.7.2] - 2026-01-10
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import base64
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import redis
|
||||
|
||||
from datetime import datetime
|
||||
|
|
@ -1015,6 +1017,39 @@ if ENV == "prod":
|
|||
OLLAMA_BASE_URL = "http://ollama-service.open-webui.svc.cluster.local:11434"
|
||||
|
||||
|
||||
def _resolve_ollama_base_url(url: str) -> str:
|
||||
"""If the default Ollama port (11434) is unreachable, try the fallback port (12434)."""
|
||||
|
||||
def reachable(host: str, port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=1.0):
|
||||
return True
|
||||
except (OSError, TimeoutError):
|
||||
return False
|
||||
|
||||
host = urlparse(url).hostname or "localhost"
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
default = pool.submit(reachable, host, 11434)
|
||||
fallback = pool.submit(reachable, host, 12434)
|
||||
|
||||
if not default.result() and fallback.result():
|
||||
url = url.replace(":11434", ":12434")
|
||||
log.info(f"Ollama port 11434 unreachable on {host}, falling back to 12434")
|
||||
elif not default.result():
|
||||
log.info(f"Ollama ports 11434 and 12434 both unreachable on {host}")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
# Auto-resolve Ollama port when no explicit URL was provided by the user.
|
||||
# The Dockerfile default is "/ollama" which the block above rewrites to :11434.
|
||||
if os.environ.get("OLLAMA_BASE_URL", "") in ("", "/ollama") and not os.environ.get(
|
||||
"OLLAMA_BASE_URLS", ""
|
||||
):
|
||||
OLLAMA_BASE_URL = _resolve_ollama_base_url(OLLAMA_BASE_URL)
|
||||
|
||||
|
||||
OLLAMA_BASE_URLS = os.environ.get("OLLAMA_BASE_URLS", "")
|
||||
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != "" else OLLAMA_BASE_URL
|
||||
|
||||
|
|
@ -1265,6 +1300,11 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
|
|||
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS = (
|
||||
os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS", "False").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT = (
|
||||
os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT", "False").lower()
|
||||
== "true"
|
||||
|
|
@ -1486,6 +1526,7 @@ DEFAULT_USER_PERMISSIONS = {
|
|||
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
|
||||
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
|
||||
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
|
||||
"skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS,
|
||||
"models_import": USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT,
|
||||
"models_export": USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT,
|
||||
"prompts_import": USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT,
|
||||
|
|
@ -2252,7 +2293,9 @@ WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051"))
|
|||
WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY")
|
||||
WEAVIATE_HTTP_SECURE = os.environ.get("WEAVIATE_HTTP_SECURE", "false").lower() == "true"
|
||||
WEAVIATE_GRPC_SECURE = os.environ.get("WEAVIATE_GRPC_SECURE", "false").lower() == "true"
|
||||
WEAVIATE_SKIP_INIT_CHECKS = os.environ.get("WEAVIATE_SKIP_INIT_CHECKS", "false").lower() == "true"
|
||||
WEAVIATE_SKIP_INIT_CHECKS = (
|
||||
os.environ.get("WEAVIATE_SKIP_INIT_CHECKS", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# OpenSearch
|
||||
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
|
||||
|
|
@ -3463,10 +3506,14 @@ IMAGE_GENERATION_MODEL = PersistentConfig(
|
|||
)
|
||||
|
||||
# Regex pattern for models that support IMAGE_SIZE = "auto".
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv("IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN", "^gpt-image")
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv(
|
||||
"IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN", "^gpt-image"
|
||||
)
|
||||
|
||||
# Regex pattern for models that return URLs instead of base64 data.
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv("IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN", "^gpt-image")
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv(
|
||||
"IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN", "^gpt-image"
|
||||
)
|
||||
|
||||
IMAGE_SIZE = PersistentConfig(
|
||||
"IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512")
|
||||
|
|
|
|||
|
|
@ -195,13 +195,26 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
|
|||
)
|
||||
|
||||
# Header names for user info forwarding (customizable via environment variables)
|
||||
FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get("FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name")
|
||||
FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get("FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id")
|
||||
FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get("FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email")
|
||||
FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get("FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role")
|
||||
FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get(
|
||||
"FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name"
|
||||
)
|
||||
FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get(
|
||||
"FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id"
|
||||
)
|
||||
FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get(
|
||||
"FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email"
|
||||
)
|
||||
FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get(
|
||||
"FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role"
|
||||
)
|
||||
|
||||
# Header name for chat ID forwarding (customizable via environment variable)
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get("FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id")
|
||||
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID = os.environ.get(
|
||||
"FORWARD_SESSION_INFO_HEADER_MESSAGE_ID", "X-OpenWebUI-Message-Id"
|
||||
)
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get(
|
||||
"FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id"
|
||||
)
|
||||
|
||||
# Experimental feature, may be removed in future
|
||||
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
|
||||
|
|
@ -401,18 +414,14 @@ try:
|
|||
REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT)
|
||||
except ValueError:
|
||||
REDIS_SOCKET_CONNECT_TIMEOUT = None
|
||||
|
||||
REDIS_RECONNECT_DELAY = os.environ.get(
|
||||
"REDIS_RECONNECT_DELAY", ""
|
||||
)
|
||||
|
||||
REDIS_RECONNECT_DELAY = os.environ.get("REDIS_RECONNECT_DELAY", "")
|
||||
|
||||
if REDIS_RECONNECT_DELAY == "":
|
||||
REDIS_RECONNECT_DELAY = None
|
||||
else:
|
||||
try:
|
||||
REDIS_RECONNECT_DELAY = float(
|
||||
REDIS_RECONNECT_DELAY
|
||||
)
|
||||
REDIS_RECONNECT_DELAY = float(REDIS_RECONNECT_DELAY)
|
||||
if REDIS_RECONNECT_DELAY < 0:
|
||||
REDIS_RECONNECT_DELAY = None
|
||||
except Exception:
|
||||
|
|
@ -580,15 +589,11 @@ LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "")
|
|||
|
||||
pk = None
|
||||
if LICENSE_PUBLIC_KEY:
|
||||
pk = serialization.load_pem_public_key(
|
||||
f"""
|
||||
pk = serialization.load_pem_public_key(f"""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
{LICENSE_PUBLIC_KEY}
|
||||
-----END PUBLIC KEY-----
|
||||
""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
""".encode("utf-8"))
|
||||
|
||||
|
||||
####################################
|
||||
|
|
@ -778,6 +783,17 @@ AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
|
|||
)
|
||||
|
||||
|
||||
RAG_EMBEDDING_TIMEOUT = os.environ.get("RAG_EMBEDDING_TIMEOUT", "")
|
||||
|
||||
if RAG_EMBEDDING_TIMEOUT == "":
|
||||
RAG_EMBEDDING_TIMEOUT = None
|
||||
else:
|
||||
try:
|
||||
RAG_EMBEDDING_TIMEOUT = int(RAG_EMBEDDING_TIMEOUT)
|
||||
except Exception:
|
||||
RAG_EMBEDDING_TIMEOUT = None
|
||||
|
||||
|
||||
####################################
|
||||
# SENTENCE TRANSFORMERS
|
||||
####################################
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ from open_webui.utils.payload import (
|
|||
apply_system_prompt_to_body,
|
||||
)
|
||||
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from contextlib import suppress
|
|||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ from open_webui.routers import (
|
|||
knowledge,
|
||||
prompts,
|
||||
evaluations,
|
||||
skills,
|
||||
tools,
|
||||
users,
|
||||
utils,
|
||||
|
|
@ -510,8 +511,8 @@ from open_webui.utils.models import (
|
|||
from open_webui.utils.chat import (
|
||||
generate_chat_completion as chat_completion_handler,
|
||||
chat_completed as chat_completed_handler,
|
||||
chat_action as chat_action_handler,
|
||||
)
|
||||
from open_webui.utils.actions import chat_action as chat_action_handler
|
||||
from open_webui.utils.embeddings import generate_embeddings
|
||||
from open_webui.utils.middleware import (
|
||||
build_chat_response_context,
|
||||
|
|
@ -552,7 +553,6 @@ from open_webui.utils.redis import get_sentinels_from_env
|
|||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
|
||||
if SAFE_MODE:
|
||||
print("SAFE MODE ENABLED")
|
||||
Functions.deactivate_all_functions()
|
||||
|
|
@ -576,8 +576,7 @@ class SPAStaticFiles(StaticFiles):
|
|||
raise ex
|
||||
|
||||
|
||||
print(
|
||||
rf"""
|
||||
print(rf"""
|
||||
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
|
||||
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
|
||||
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
|
||||
|
|
@ -589,12 +588,15 @@ print(
|
|||
v{VERSION} - building the best AI user interface.
|
||||
{f"Commit: {WEBUI_BUILD_HASH}" if WEBUI_BUILD_HASH != "dev-build" else ""}
|
||||
https://github.com/open-webui/open-webui
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Store reference to main event loop for sync->async calls (e.g., embedding generation)
|
||||
# This allows sync functions to schedule work on the main loop without blocking health checks
|
||||
app.state.main_loop = asyncio.get_running_loop()
|
||||
|
||||
app.state.instance_id = INSTANCE_ID
|
||||
start_logger()
|
||||
|
||||
|
|
@ -817,6 +819,21 @@ app.state.config.ENABLE_USER_STATUS = ENABLE_USER_STATUS
|
|||
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
|
||||
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
|
||||
|
||||
# Migrate legacy access_control → access_grants on boot
|
||||
from open_webui.utils.access_control import migrate_access_control
|
||||
|
||||
connections = app.state.config.TOOL_SERVER_CONNECTIONS
|
||||
if any("access_control" in c.get("config", {}) for c in connections):
|
||||
for connection in connections:
|
||||
migrate_access_control(connection.get("config", {}))
|
||||
app.state.config.TOOL_SERVER_CONNECTIONS = connections
|
||||
|
||||
arena_models = app.state.config.EVALUATION_ARENA_MODELS
|
||||
if any("access_control" in m.get("meta", {}) for m in arena_models):
|
||||
for model in arena_models:
|
||||
migrate_access_control(model.get("meta", {}))
|
||||
app.state.config.EVALUATION_ARENA_MODELS = arena_models
|
||||
|
||||
app.state.config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
|
||||
app.state.config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
|
||||
app.state.config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
|
||||
|
|
@ -1375,7 +1392,13 @@ app.add_middleware(APIKeyRestrictionMiddleware)
|
|||
async def commit_session_after_request(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
# log.debug("Commit session after request")
|
||||
ScopedSession.commit()
|
||||
try:
|
||||
ScopedSession.commit()
|
||||
finally:
|
||||
# CRITICAL: remove() returns the connection to the pool.
|
||||
# Without this, connections remain "checked out" and accumulate
|
||||
# as "idle in transaction" in PostgreSQL.
|
||||
ScopedSession.remove()
|
||||
return response
|
||||
|
||||
|
||||
|
|
@ -1456,6 +1479,7 @@ app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
|
|||
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||
app.include_router(prompts.router, prefix="/api/v1/prompts", tags=["prompts"])
|
||||
app.include_router(tools.router, prefix="/api/v1/tools", tags=["tools"])
|
||||
app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"])
|
||||
|
||||
app.include_router(memories.router, prefix="/api/v1/memories", tags=["memories"])
|
||||
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
||||
|
|
@ -1832,9 +1856,7 @@ async def chat_completion(
|
|||
# Emit chat:active=true when task starts
|
||||
event_emitter = get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter(
|
||||
{"type": "chat:active", "data": {"active": True}}
|
||||
)
|
||||
await event_emitter({"type": "chat:active", "data": {"active": True}})
|
||||
return {"status": True, "task_id": task_id}
|
||||
else:
|
||||
return await process_chat(request, form_data, user, metadata, model)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from alembic import op
|
|||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2f1211949ecc"
|
||||
down_revision: Union[str, None] = "37f288994c47"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import uuid
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "374d2f66af06"
|
||||
down_revision: Union[str, None] = "c440947495f3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from typing import Sequence, Union
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "37f288994c47"
|
||||
down_revision: Union[str, None] = "a5c220713937"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from typing import Sequence, Union
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "38d63c18f30f"
|
||||
down_revision: Union[str, None] = "3af16a1c9fb6"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from alembic import op
|
|||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6283dc0e4d8d"
|
||||
down_revision: Union[str, None] = "3e0e00844bb0"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import sqlalchemy as sa
|
|||
from sqlalchemy.sql import table, column, select
|
||||
import json
|
||||
|
||||
|
||||
revision = "6a39f3d8e55c"
|
||||
down_revision = "c0fbf31ca0db"
|
||||
branch_labels = None
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from alembic import op
|
|||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "81cc2ce44d79"
|
||||
down_revision: Union[str, None] = "6283dc0e4d8d"
|
||||
|
|
|
|||
|
|
@ -165,7 +165,9 @@ def upgrade() -> None:
|
|||
log.warning(f"Failed to insert message {message_id}: {e}")
|
||||
continue
|
||||
|
||||
log.info(f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)")
|
||||
log.info(
|
||||
f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from alembic import op
|
|||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "90ef40d4714e"
|
||||
down_revision: Union[str, None] = "b10670c03dd5"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
"""Add skill table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f1e2d3c4b5a6
|
||||
Create Date: 2026-02-11 09:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "f1e2d3c4b5a6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
if "skill" not in existing_tables:
|
||||
op.create_table(
|
||||
"skill",
|
||||
sa.Column("id", sa.String(), nullable=False, primary_key=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
op.create_index("idx_skill_user_id", "skill", ["user_id"])
|
||||
op.create_index("idx_skill_updated_at", "skill", ["updated_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_skill_updated_at", table_name="skill")
|
||||
op.drop_index("idx_skill_user_id", table_name="skill")
|
||||
op.drop_table("skill")
|
||||
|
|
@ -173,12 +173,10 @@ def upgrade() -> None:
|
|||
for uid, api_key in users_with_keys:
|
||||
if api_key:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
sa.text("""
|
||||
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
|
||||
VALUES (:id, :user_id, :key, :created_at, :updated_at)
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": f"key_{uid}",
|
||||
"user_id": uid,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import json
|
|||
from sqlalchemy.sql import table, column
|
||||
from sqlalchemy import String, Text, JSON, and_
|
||||
|
||||
|
||||
revision = "c29facfe716b"
|
||||
down_revision = "c69f45358db4"
|
||||
branch_labels = None
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from typing import Sequence, Union
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c440947495f3"
|
||||
down_revision: Union[str, None] = "81cc2ce44d79"
|
||||
|
|
|
|||
|
|
@ -98,26 +98,27 @@ def upgrade() -> None:
|
|||
# Could be Python None (SQL NULL) or string "null" (JSON null)
|
||||
# EXCEPTION: files with NULL are PRIVATE (owner-only), not public
|
||||
is_null = (
|
||||
access_control_json is None or
|
||||
access_control_json == "null" or
|
||||
(isinstance(access_control_json, str) and access_control_json.strip().lower() == "null")
|
||||
access_control_json is None
|
||||
or access_control_json == "null"
|
||||
or (
|
||||
isinstance(access_control_json, str)
|
||||
and access_control_json.strip().lower() == "null"
|
||||
)
|
||||
)
|
||||
if is_null:
|
||||
# Files: NULL = private (no entry needed, owner has implicit access)
|
||||
# Other resources: NULL = public (insert user:* for read)
|
||||
if resource_type == "file":
|
||||
continue # Private - no entry needed
|
||||
|
||||
|
||||
key = (resource_type, resource_id, "user", "*", "read")
|
||||
if key not in inserted:
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
sa.text("""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
|
|
@ -174,12 +175,10 @@ def upgrade() -> None:
|
|||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
sa.text("""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
|
|
@ -200,12 +199,10 @@ def upgrade() -> None:
|
|||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
sa.text("""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
|
|
@ -233,9 +230,9 @@ def upgrade() -> None:
|
|||
|
||||
def downgrade() -> None:
|
||||
import json
|
||||
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
|
||||
# Resource tables mapping: (table_name, resource_type)
|
||||
resource_tables = [
|
||||
("knowledge", "knowledge"),
|
||||
|
|
@ -265,7 +262,7 @@ def downgrade() -> None:
|
|||
FROM access_grant
|
||||
WHERE resource_type = :resource_type
|
||||
"""),
|
||||
{"resource_type": resource_type}
|
||||
{"resource_type": resource_type},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
except Exception:
|
||||
|
|
@ -287,39 +284,61 @@ def downgrade() -> None:
|
|||
}
|
||||
|
||||
# Handle public access (user:* for read)
|
||||
if principal_type == "user" and principal_id == "*" and permission == "read":
|
||||
if (
|
||||
principal_type == "user"
|
||||
and principal_id == "*"
|
||||
and permission == "read"
|
||||
):
|
||||
resource_grants[resource_id]["is_public"] = True
|
||||
continue
|
||||
|
||||
# Add to appropriate list
|
||||
if permission in ["read", "write"]:
|
||||
if principal_type == "group":
|
||||
if principal_id not in resource_grants[resource_id][permission]["group_ids"]:
|
||||
resource_grants[resource_id][permission]["group_ids"].append(principal_id)
|
||||
if (
|
||||
principal_id
|
||||
not in resource_grants[resource_id][permission]["group_ids"]
|
||||
):
|
||||
resource_grants[resource_id][permission]["group_ids"].append(
|
||||
principal_id
|
||||
)
|
||||
elif principal_type == "user":
|
||||
if principal_id not in resource_grants[resource_id][permission]["user_ids"]:
|
||||
resource_grants[resource_id][permission]["user_ids"].append(principal_id)
|
||||
if (
|
||||
principal_id
|
||||
not in resource_grants[resource_id][permission]["user_ids"]
|
||||
):
|
||||
resource_grants[resource_id][permission]["user_ids"].append(
|
||||
principal_id
|
||||
)
|
||||
|
||||
# Step 3: Update each resource with reconstructed JSON
|
||||
for resource_id, grants in resource_grants.items():
|
||||
if grants["is_public"]:
|
||||
# Public = NULL
|
||||
access_control_value = None
|
||||
elif (not grants["read"]["group_ids"] and not grants["read"]["user_ids"] and
|
||||
not grants["write"]["group_ids"] and not grants["write"]["user_ids"]):
|
||||
elif (
|
||||
not grants["read"]["group_ids"]
|
||||
and not grants["read"]["user_ids"]
|
||||
and not grants["write"]["group_ids"]
|
||||
and not grants["write"]["user_ids"]
|
||||
):
|
||||
# No grants = should not happen (would mean no entries), default to {}
|
||||
access_control_value = json.dumps({})
|
||||
else:
|
||||
# Custom permissions
|
||||
access_control_value = json.dumps({
|
||||
"read": grants["read"],
|
||||
"write": grants["write"],
|
||||
})
|
||||
access_control_value = json.dumps(
|
||||
{
|
||||
"read": grants["read"],
|
||||
"write": grants["write"],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'),
|
||||
{"access_control": access_control_value, "id": resource_id}
|
||||
sa.text(
|
||||
f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'
|
||||
),
|
||||
{"access_control": access_control_value, "id": resource_id},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -330,15 +349,15 @@ def downgrade() -> None:
|
|||
if resource_type != "file":
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(f'''
|
||||
sa.text(f"""
|
||||
UPDATE "{table_name}"
|
||||
SET access_control = :private_value
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT resource_id FROM access_grant WHERE resource_type = :resource_type
|
||||
)
|
||||
AND access_control IS NULL
|
||||
'''),
|
||||
{"private_value": json.dumps({}), "resource_type": resource_type}
|
||||
"""),
|
||||
{"private_value": json.dumps({}), "resource_type": resource_type},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -22,10 +22,14 @@ class AccessGrant(Base):
|
|||
__tablename__ = "access_grant"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
|
||||
resource_type = Column(
|
||||
Text, nullable=False
|
||||
) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
|
||||
resource_id = Column(Text, nullable=False)
|
||||
principal_type = Column(Text, nullable=False) # "user" or "group"
|
||||
principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public)
|
||||
principal_id = Column(
|
||||
Text, nullable=False
|
||||
) # user_id, group_id, or "*" (wildcard for public)
|
||||
permission = Column(Text, nullable=False) # "read" or "write"
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
|
||||
|
|
@ -173,9 +177,11 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
|
|||
|
||||
key = (principal_type, principal_id, permission)
|
||||
deduped[key] = {
|
||||
"id": grant.get("id")
|
||||
if isinstance(grant.get("id"), str) and grant.get("id")
|
||||
else str(uuid.uuid4()),
|
||||
"id": (
|
||||
grant.get("id")
|
||||
if isinstance(grant.get("id"), str) and grant.get("id")
|
||||
else str(uuid.uuid4())
|
||||
),
|
||||
"principal_type": principal_type,
|
||||
"principal_id": principal_id,
|
||||
"permission": permission,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from typing import Optional
|
|||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
|
||||
from pydantic import BaseModel
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import Boolean, Column, String, Text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -74,6 +75,13 @@ class SignupForm(BaseModel):
|
|||
password: str
|
||||
profile_image_url: Optional[str] = "/user.png"
|
||||
|
||||
@field_validator("profile_image_url")
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None:
|
||||
return validate_profile_image_url(v)
|
||||
return v
|
||||
|
||||
|
||||
class AddUserForm(SignupForm):
|
||||
role: Optional[str] = "pending"
|
||||
|
|
|
|||
|
|
@ -263,7 +263,9 @@ class ChannelTable:
|
|||
def _to_channel_model(
|
||||
self, channel: Channel, db: Optional[Session] = None
|
||||
) -> ChannelModel:
|
||||
channel_data = ChannelModel.model_validate(channel).model_dump(exclude={"access_grants"})
|
||||
channel_data = ChannelModel.model_validate(channel).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
access_grants = self._get_access_grants(channel_data["id"], db=db)
|
||||
channel_data["access_grants"] = access_grants
|
||||
return ChannelModel.model_validate(channel_data)
|
||||
|
|
@ -770,9 +772,7 @@ class ChannelTable:
|
|||
.first()
|
||||
)
|
||||
if membership:
|
||||
allowed_channels.append(
|
||||
self._to_channel_model(channel, db=db)
|
||||
)
|
||||
allowed_channels.append(self._to_channel_model(channel, db=db))
|
||||
continue
|
||||
|
||||
# --- Case B: standard channel => rely on ACL permissions ---
|
||||
|
|
|
|||
|
|
@ -332,7 +332,11 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.model_id).all()
|
||||
|
|
@ -362,10 +366,12 @@ class ChatMessageTable:
|
|||
elif dialect == "postgresql":
|
||||
# Use json_extract_path_text for PostgreSQL JSON columns
|
||||
input_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "input_tokens"), Integer
|
||||
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
|
||||
Integer,
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "output_tokens"), Integer
|
||||
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
|
||||
Integer,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||
|
|
@ -387,7 +393,11 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.model_id).all()
|
||||
|
|
@ -424,10 +434,12 @@ class ChatMessageTable:
|
|||
elif dialect == "postgresql":
|
||||
# Use json_extract_path_text for PostgreSQL JSON columns
|
||||
input_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "input_tokens"), Integer
|
||||
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
|
||||
Integer,
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "output_tokens"), Integer
|
||||
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
|
||||
Integer,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||
|
|
@ -481,7 +493,11 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.user_id).all()
|
||||
|
|
@ -507,7 +523,11 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.chat_id).all()
|
||||
|
|
@ -536,7 +556,11 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.all()
|
||||
|
|
@ -544,10 +568,14 @@ class ChatMessageTable:
|
|||
# Group by date -> model -> count
|
||||
daily_counts: dict[str, dict[str, int]] = {}
|
||||
for timestamp, model_id in results:
|
||||
date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime("%Y-%m-%d")
|
||||
date_str = datetime.fromtimestamp(
|
||||
_normalize_timestamp(timestamp)
|
||||
).strftime("%Y-%m-%d")
|
||||
if date_str not in daily_counts:
|
||||
daily_counts[date_str] = {}
|
||||
daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1
|
||||
daily_counts[date_str][model_id] = (
|
||||
daily_counts[date_str].get(model_id, 0) + 1
|
||||
)
|
||||
|
||||
# Fill in missing days
|
||||
if start_date and end_date:
|
||||
|
|
@ -587,14 +615,20 @@ class ChatMessageTable:
|
|||
# Group by hour -> model -> count
|
||||
hourly_counts: dict[str, dict[str, int]] = {}
|
||||
for timestamp, model_id in results:
|
||||
hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime("%Y-%m-%d %H:00")
|
||||
hour_str = datetime.fromtimestamp(
|
||||
_normalize_timestamp(timestamp)
|
||||
).strftime("%Y-%m-%d %H:00")
|
||||
if hour_str not in hourly_counts:
|
||||
hourly_counts[hour_str] = {}
|
||||
hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1
|
||||
hourly_counts[hour_str][model_id] = (
|
||||
hourly_counts[hour_str].get(model_id, 0) + 1
|
||||
)
|
||||
|
||||
# Fill in missing hours
|
||||
if start_date and end_date:
|
||||
current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace(minute=0, second=0, microsecond=0)
|
||||
current = datetime.fromtimestamp(
|
||||
_normalize_timestamp(start_date)
|
||||
).replace(minute=0, second=0, microsecond=0)
|
||||
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
|
||||
while current <= end_dt:
|
||||
hour_str = current.strftime("%Y-%m-%d %H:00")
|
||||
|
|
|
|||
|
|
@ -329,7 +329,9 @@ class ChatTable:
|
|||
data=message,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to write initial messages to chat_message table: {e}")
|
||||
log.warning(
|
||||
f"Failed to write initial messages to chat_message table: {e}"
|
||||
)
|
||||
|
||||
return ChatModel.model_validate(chat_item) if chat_item else None
|
||||
|
||||
|
|
@ -388,7 +390,9 @@ class ChatTable:
|
|||
data=message,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to write imported messages to chat_message table: {e}")
|
||||
log.warning(
|
||||
f"Failed to write imported messages to chat_message table: {e}"
|
||||
)
|
||||
|
||||
return [ChatModel.model_validate(chat) for chat in chats]
|
||||
|
||||
|
|
@ -739,8 +743,10 @@ class ChatTable:
|
|||
) -> list[ChatModel]:
|
||||
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id).filter(
|
||||
Chat.share_id.isnot(None)
|
||||
query = (
|
||||
db.query(Chat)
|
||||
.filter_by(user_id=user_id)
|
||||
.filter(Chat.share_id.isnot(None))
|
||||
)
|
||||
|
||||
if filter:
|
||||
|
|
@ -1110,29 +1116,23 @@ class ChatTable:
|
|||
|
||||
# Check if there are any tags to filter, it should have all the tags
|
||||
if "none" in tag_ids:
|
||||
query = query.filter(
|
||||
text(
|
||||
"""
|
||||
query = query.filter(text("""
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.meta, '$.tags') AS tag
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
"""))
|
||||
elif tag_ids:
|
||||
query = query.filter(
|
||||
and_(
|
||||
*[
|
||||
text(
|
||||
f"""
|
||||
text(f"""
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.meta, '$.tags') AS tag
|
||||
WHERE tag.value = :tag_id_{tag_idx}
|
||||
)
|
||||
"""
|
||||
).params(**{f"tag_id_{tag_idx}": tag_id})
|
||||
""").params(**{f"tag_id_{tag_idx}": tag_id})
|
||||
for tag_idx, tag_id in enumerate(tag_ids)
|
||||
]
|
||||
)
|
||||
|
|
@ -1168,29 +1168,23 @@ class ChatTable:
|
|||
|
||||
# Check if there are any tags to filter, it should have all the tags
|
||||
if "none" in tag_ids:
|
||||
query = query.filter(
|
||||
text(
|
||||
"""
|
||||
query = query.filter(text("""
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements_text(Chat.meta->'tags') AS tag
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
"""))
|
||||
elif tag_ids:
|
||||
query = query.filter(
|
||||
and_(
|
||||
*[
|
||||
text(
|
||||
f"""
|
||||
text(f"""
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements_text(Chat.meta->'tags') AS tag
|
||||
WHERE tag = :tag_id_{tag_idx}
|
||||
)
|
||||
"""
|
||||
).params(**{f"tag_id_{tag_idx}": tag_id})
|
||||
""").params(**{f"tag_id_{tag_idx}": tag_id})
|
||||
for tag_idx, tag_id in enumerate(tag_ids)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class FileMeta(BaseModel):
|
|||
"""Sanitize metadata fields to handle malformed legacy data."""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
|
||||
# Handle content_type that may be a list like ['application/pdf', None]
|
||||
content_type = data.get("content_type")
|
||||
if isinstance(content_type, list):
|
||||
|
|
@ -75,7 +75,7 @@ class FileMeta(BaseModel):
|
|||
)
|
||||
elif content_type is not None and not isinstance(content_type, str):
|
||||
data["content_type"] = None
|
||||
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -214,7 +214,6 @@ class FunctionsTable:
|
|||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_functions(
|
||||
self, active_only=False, include_valves=False, db: Optional[Session] = None
|
||||
) -> list[FunctionModel | FunctionWithValvesModel]:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ from sqlalchemy import (
|
|||
select,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
|
|
@ -182,12 +181,12 @@ class GroupTable:
|
|||
if share_value:
|
||||
# Groups open to anyone: data is null, config.share is null, or share is true
|
||||
# Use case-insensitive string comparison to handle variations like "True", "TRUE"
|
||||
# Handle potential JSON boolean to string casting issues by checking for both string 'true' and boolean equivalence if possible,
|
||||
# Handle potential JSON boolean to string casting issues by checking for both string 'true' and boolean equivalence if possible,
|
||||
anyone_can_share = or_(
|
||||
Group.data.is_(None),
|
||||
json_share_str.is_(None),
|
||||
json_share_lower == "true",
|
||||
json_share_lower == "1", # Handle SQLite boolean true
|
||||
json_share_lower == "1", # Handle SQLite boolean true
|
||||
)
|
||||
|
||||
if member_id:
|
||||
|
|
@ -217,13 +216,13 @@ class GroupTable:
|
|||
).filter(GroupMember.user_id == filter["member_id"])
|
||||
|
||||
groups = query.order_by(Group.updated_at.desc()).all()
|
||||
group_ids = [group.id for group in groups]
|
||||
member_counts = self.get_group_member_counts_by_ids(group_ids, db=db)
|
||||
return [
|
||||
GroupResponse.model_validate(
|
||||
{
|
||||
**GroupModel.model_validate(group).model_dump(),
|
||||
"member_count": self.get_group_member_count_by_id(
|
||||
group.id, db=db
|
||||
),
|
||||
"member_count": member_counts.get(group.id, 0),
|
||||
}
|
||||
)
|
||||
for group in groups
|
||||
|
|
@ -258,12 +257,14 @@ class GroupTable:
|
|||
total = query.count()
|
||||
query = query.order_by(Group.updated_at.desc())
|
||||
groups = query.offset(skip).limit(limit).all()
|
||||
group_ids = [group.id for group in groups]
|
||||
member_counts = self.get_group_member_counts_by_ids(group_ids, db=db)
|
||||
|
||||
return {
|
||||
"items": [
|
||||
GroupResponse.model_validate(
|
||||
**GroupModel.model_validate(group).model_dump(),
|
||||
member_count=self.get_group_member_count_by_id(group.id, db=db),
|
||||
member_count=member_counts.get(group.id, 0),
|
||||
)
|
||||
for group in groups
|
||||
],
|
||||
|
|
@ -380,6 +381,20 @@ class GroupTable:
|
|||
)
|
||||
return count if count else 0
|
||||
|
||||
def get_group_member_counts_by_ids(
|
||||
self, ids: list[str], db: Optional[Session] = None
|
||||
) -> dict[str, int]:
|
||||
if not ids:
|
||||
return {}
|
||||
with get_db_context(db) as db:
|
||||
rows = (
|
||||
db.query(GroupMember.group_id, func.count(GroupMember.user_id))
|
||||
.filter(GroupMember.group_id.in_(ids))
|
||||
.group_by(GroupMember.group_id)
|
||||
.all()
|
||||
)
|
||||
return {group_id: count for group_id, count in rows}
|
||||
|
||||
def update_group_by_id(
|
||||
self,
|
||||
id: str,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ from sqlalchemy import (
|
|||
or_,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
|
|
@ -402,9 +401,7 @@ class KnowledgeTable:
|
|||
try:
|
||||
with get_db_context(db) as db:
|
||||
knowledge = db.query(Knowledge).filter_by(id=id).first()
|
||||
return (
|
||||
self._to_knowledge_model(knowledge, db=db) if knowledge else None
|
||||
)
|
||||
return self._to_knowledge_model(knowledge, db=db) if knowledge else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -443,7 +440,10 @@ class KnowledgeTable:
|
|||
.filter(KnowledgeFile.file_id == file_id)
|
||||
.all()
|
||||
)
|
||||
return [self._to_knowledge_model(knowledge, db=db) for knowledge in knowledges]
|
||||
return [
|
||||
self._to_knowledge_model(knowledge, db=db)
|
||||
for knowledge in knowledges
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
|
@ -484,11 +484,17 @@ class KnowledgeTable:
|
|||
is_asc = direction == "asc"
|
||||
|
||||
if order_by == "name":
|
||||
primary_sort = File.filename.asc() if is_asc else File.filename.desc()
|
||||
primary_sort = (
|
||||
File.filename.asc() if is_asc else File.filename.desc()
|
||||
)
|
||||
elif order_by == "created_at":
|
||||
primary_sort = File.created_at.asc() if is_asc else File.created_at.desc()
|
||||
primary_sort = (
|
||||
File.created_at.asc() if is_asc else File.created_at.desc()
|
||||
)
|
||||
elif order_by == "updated_at":
|
||||
primary_sort = File.updated_at.asc() if is_asc else File.updated_at.desc()
|
||||
primary_sort = (
|
||||
File.updated_at.asc() if is_asc else File.updated_at.desc()
|
||||
)
|
||||
|
||||
# Apply sort with secondary key for deterministic pagination
|
||||
query = query.order_by(primary_sort, File.id.asc())
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ from sqlalchemy.dialects import postgresql, sqlite
|
|||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import BigInteger, Column, Text, Boolean
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -182,7 +181,9 @@ class ModelsTable:
|
|||
|
||||
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
return [self._to_model_model(model, db=db) for model in db.query(Model).all()]
|
||||
return [
|
||||
self._to_model_model(model, db=db) for model in db.query(Model).all()
|
||||
]
|
||||
|
||||
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
|
||||
with get_db_context(db) as db:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from open_webui.models.users import Users, UserResponse
|
|||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, Index
|
||||
|
||||
|
||||
####################
|
||||
# PromptHistory DB Schema
|
||||
####################
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
|
||||
|
||||
|
||||
####################
|
||||
# Prompts DB Schema
|
||||
####################
|
||||
|
|
@ -146,7 +145,9 @@ class PromptsTable:
|
|||
"data": form_data.data or {},
|
||||
"meta": form_data.meta or {},
|
||||
"tags": form_data.tags or [],
|
||||
"access_grants": [grant.model_dump() for grant in current_access_grants],
|
||||
"access_grants": [
|
||||
grant.model_dump() for grant in current_access_grants
|
||||
],
|
||||
}
|
||||
|
||||
history_entry = PromptHistories.create_history_entry(
|
||||
|
|
@ -345,7 +346,6 @@ class PromptsTable:
|
|||
return PromptListResponse(items=prompts, total=total)
|
||||
|
||||
def update_prompt_by_command(
|
||||
|
||||
self,
|
||||
command: str,
|
||||
form_data: PromptForm,
|
||||
|
|
@ -450,7 +450,7 @@ class PromptsTable:
|
|||
prompt.content = form_data.content
|
||||
prompt.data = form_data.data or prompt.data
|
||||
prompt.meta = form_data.meta or prompt.meta
|
||||
|
||||
|
||||
if form_data.tags is not None:
|
||||
prompt.tags = form_data.tags
|
||||
|
||||
|
|
@ -459,7 +459,7 @@ class PromptsTable:
|
|||
"prompt", prompt.id, form_data.access_grants, db=db
|
||||
)
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
|
||||
prompt.updated_at = int(time.time())
|
||||
|
||||
db.commit()
|
||||
|
|
@ -504,22 +504,22 @@ class PromptsTable:
|
|||
tags: Optional[list[str]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[PromptModel]:
|
||||
"""Update only name and command (no history created)."""
|
||||
"""Update only name, command, and tags (no history created)."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if not prompt:
|
||||
return None
|
||||
|
||||
|
||||
prompt.name = name
|
||||
prompt.command = command
|
||||
|
||||
|
||||
if tags is not None:
|
||||
prompt.tags = tags
|
||||
|
||||
|
||||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
|
||||
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
339
backend/open_webui/models/skills.py
Normal file
339
backend/open_webui/models/skills.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, or_
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
# Skills DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class Skill(Base):
|
||||
__tablename__ = "skill"
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
user_id = Column(String)
|
||||
name = Column(Text, unique=True)
|
||||
description = Column(Text, nullable=True)
|
||||
content = Column(Text)
|
||||
meta = Column(JSONField)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
|
||||
class SkillMeta(BaseModel):
|
||||
tags: Optional[list[str]] = []
|
||||
|
||||
|
||||
class SkillModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
content: str
|
||||
meta: SkillMeta
|
||||
is_active: bool = True
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class SkillUserModel(SkillModel):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class SkillResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
meta: SkillMeta
|
||||
is_active: bool = True
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
|
||||
class SkillUserResponse(SkillResponse):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class SkillAccessResponse(SkillUserResponse):
|
||||
write_access: Optional[bool] = False
|
||||
|
||||
|
||||
class SkillForm(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
content: str
|
||||
meta: SkillMeta = SkillMeta()
|
||||
is_active: bool = True
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SkillListResponse(BaseModel):
|
||||
items: list[SkillUserResponse] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class SkillAccessListResponse(BaseModel):
|
||||
items: list[SkillAccessResponse] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class SkillsTable:
|
||||
def _get_access_grants(
|
||||
self, skill_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("skill", skill_id, db=db)
|
||||
|
||||
def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel:
|
||||
skill_data = SkillModel.model_validate(skill).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db)
|
||||
return SkillModel.model_validate(skill_data)
|
||||
|
||||
def insert_new_skill(
|
||||
self,
|
||||
user_id: str,
|
||||
form_data: SkillForm,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[SkillModel]:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
result = Skill(
|
||||
**{
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"skill", result.id, form_data.access_grants, db=db
|
||||
)
|
||||
if result:
|
||||
return self._to_skill_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f"Error creating a new skill: {e}")
|
||||
return None
|
||||
|
||||
def get_skill_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[SkillModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
skill = db.get(Skill, id)
|
||||
return self._to_skill_model(skill, db=db) if skill else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_skill_by_name(
|
||||
self, name: str, db: Optional[Session] = None
|
||||
) -> Optional[SkillModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
skill = db.query(Skill).filter_by(name=name).first()
|
||||
return self._to_skill_model(skill, db=db) if skill else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]:
|
||||
with get_db_context(db) as db:
|
||||
all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all()
|
||||
|
||||
user_ids = list(set(skill.user_id for skill in all_skills))
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
skills = []
|
||||
for skill in all_skills:
|
||||
user = users_dict.get(skill.user_id)
|
||||
skills.append(
|
||||
SkillUserModel.model_validate(
|
||||
{
|
||||
**self._to_skill_model(skill, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
)
|
||||
return skills
|
||||
|
||||
def get_skills_by_user_id(
|
||||
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||
) -> list[SkillUserModel]:
|
||||
skills = self.get_skills(db=db)
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||
}
|
||||
|
||||
return [
|
||||
skill
|
||||
for skill in skills
|
||||
if skill.user_id == user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
def search_skills(
|
||||
self,
|
||||
user_id: str,
|
||||
filter: dict = {},
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[Session] = None,
|
||||
) -> SkillListResponse:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
from open_webui.models.users import User, UserModel
|
||||
|
||||
# Join with User table for user filtering
|
||||
query = db.query(Skill, User).outerjoin(User, User.id == Skill.user_id)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Skill.name.ilike(f"%{query_key}%"),
|
||||
Skill.description.ilike(f"%{query_key}%"),
|
||||
Skill.id.ilike(f"%{query_key}%"),
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
view_option = filter.get("view_option")
|
||||
if view_option == "created":
|
||||
query = query.filter(Skill.user_id == user_id)
|
||||
elif view_option == "shared":
|
||||
query = query.filter(Skill.user_id != user_id)
|
||||
|
||||
# Apply access grant filtering
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Skill,
|
||||
filter=filter,
|
||||
resource_type="skill",
|
||||
permission="read",
|
||||
)
|
||||
|
||||
query = query.order_by(Skill.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
items = query.all()
|
||||
|
||||
skills = []
|
||||
for skill, user in items:
|
||||
skills.append(
|
||||
SkillUserResponse(
|
||||
**self._to_skill_model(skill, db=db).model_dump(),
|
||||
user=(
|
||||
UserResponse(
|
||||
**UserModel.model_validate(user).model_dump()
|
||||
)
|
||||
if user
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return SkillListResponse(items=skills, total=total)
|
||||
except Exception as e:
|
||||
log.exception(f"Error searching skills: {e}")
|
||||
return SkillListResponse(items=[], total=0)
|
||||
|
||||
def update_skill_by_id(
|
||||
self, id: str, updated: dict, db: Optional[Session] = None
|
||||
) -> Optional[SkillModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
access_grants = updated.pop("access_grants", None)
|
||||
db.query(Skill).filter_by(id=id).update(
|
||||
{**updated, "updated_at": int(time.time())}
|
||||
)
|
||||
db.commit()
|
||||
if access_grants is not None:
|
||||
AccessGrants.set_access_grants("skill", id, access_grants, db=db)
|
||||
|
||||
skill = db.query(Skill).get(id)
|
||||
db.refresh(skill)
|
||||
return self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def toggle_skill_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[SkillModel]:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
skill = db.query(Skill).filter_by(id=id).first()
|
||||
if not skill:
|
||||
return None
|
||||
|
||||
skill.is_active = not skill.is_active
|
||||
skill.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(skill)
|
||||
|
||||
return self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("skill", id, db=db)
|
||||
db.query(Skill).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
Skills = SkillsTable()
|
||||
|
|
@ -11,7 +11,6 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Column, String, Text
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ from open_webui.models.groups import Groups, GroupMember
|
|||
from open_webui.models.channels import ChannelMember
|
||||
|
||||
from open_webui.utils.misc import throttle
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
JSON,
|
||||
|
|
@ -154,6 +155,11 @@ class UpdateProfileForm(BaseModel):
|
|||
gender: Optional[str] = None
|
||||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
@field_validator("profile_image_url")
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: str) -> str:
|
||||
return validate_profile_image_url(v)
|
||||
|
||||
|
||||
class UserGroupIdsModel(UserModel):
|
||||
group_ids: list[str] = []
|
||||
|
|
@ -237,6 +243,11 @@ class UserUpdateForm(BaseModel):
|
|||
profile_image_url: str
|
||||
password: Optional[str] = None
|
||||
|
||||
@field_validator("profile_image_url")
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: str) -> str:
|
||||
return validate_profile_image_url(v)
|
||||
|
||||
|
||||
class UsersTable:
|
||||
def insert_new_user(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, REQUESTS_VERIFY
|
|||
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -601,7 +601,10 @@ async def agenerate_openai_batch_embeddings(
|
|||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(
|
||||
f"{url}/embeddings", headers=headers, json=form_data
|
||||
f"{url}/embeddings",
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
|
|
@ -691,7 +694,12 @@ async def agenerate_azure_openai_batch_embeddings(
|
|||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(full_url, headers=headers, json=form_data) as r:
|
||||
async with session.post(
|
||||
full_url,
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
if "data" in data:
|
||||
|
|
|
|||
|
|
@ -256,8 +256,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
with connection.cursor() as cursor:
|
||||
try:
|
||||
log.info("Creating Table document_chunk")
|
||||
cursor.execute(
|
||||
"""
|
||||
cursor.execute("""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE TABLE IF NOT EXISTS document_chunk (
|
||||
|
|
@ -274,12 +273,10 @@ class Oracle23aiClient(VectorDBBase):
|
|||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
log.info("Creating Index document_chunk_collection_name_idx")
|
||||
cursor.execute(
|
||||
"""
|
||||
cursor.execute("""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx
|
||||
|
|
@ -291,12 +288,10 @@ class Oracle23aiClient(VectorDBBase):
|
|||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx")
|
||||
cursor.execute(
|
||||
"""
|
||||
cursor.execute("""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx
|
||||
|
|
@ -312,8 +307,7 @@ class Oracle23aiClient(VectorDBBase):
|
|||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
connection.commit()
|
||||
log.info("Database initialization completed successfully.")
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ from open_webui.config import (
|
|||
PGVECTOR_USE_HALFVEC,
|
||||
)
|
||||
|
||||
|
||||
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
|
||||
USE_HALFVEC = PGVECTOR_USE_HALFVEC
|
||||
|
||||
|
|
@ -121,34 +120,26 @@ class PgvectorClient(VectorDBBase):
|
|||
# Ensure the pgvector extension is available
|
||||
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||
if PGVECTOR_CREATE_EXTENSION:
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
self.session.execute(text("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
"""))
|
||||
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
# Ensure the pgcrypto extension is available for encryption
|
||||
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
self.session.execute(text("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
"""))
|
||||
|
||||
if not PGVECTOR_PGCRYPTO_KEY:
|
||||
raise ValueError(
|
||||
|
|
@ -216,15 +207,13 @@ class PgvectorClient(VectorDBBase):
|
|||
def _ensure_vector_index(self, index_method: str, index_options: str) -> None:
|
||||
index_name = "idx_document_chunk_vector"
|
||||
existing_index_def = self.session.execute(
|
||||
text(
|
||||
"""
|
||||
text("""
|
||||
SELECT indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename = 'document_chunk'
|
||||
AND indexname = :index_name
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{"index_name": index_name},
|
||||
).scalar()
|
||||
|
||||
|
|
@ -310,8 +299,7 @@ class PgvectorClient(VectorDBBase):
|
|||
# Ensure metadata is converted to its JSON text representation
|
||||
json_metadata = json.dumps(item["metadata"])
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
text("""
|
||||
INSERT INTO document_chunk
|
||||
(id, vector, collection_name, text, vmetadata)
|
||||
VALUES (
|
||||
|
|
@ -320,8 +308,7 @@ class PgvectorClient(VectorDBBase):
|
|||
pgp_sym_encrypt(:metadata_text, :key)
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": item["id"],
|
||||
"vector": vector,
|
||||
|
|
@ -363,8 +350,7 @@ class PgvectorClient(VectorDBBase):
|
|||
vector = self.adjust_vector_length(item["vector"])
|
||||
json_metadata = json.dumps(item["metadata"])
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
text("""
|
||||
INSERT INTO document_chunk
|
||||
(id, vector, collection_name, text, vmetadata)
|
||||
VALUES (
|
||||
|
|
@ -377,8 +363,7 @@ class PgvectorClient(VectorDBBase):
|
|||
collection_name = EXCLUDED.collection_name,
|
||||
text = EXCLUDED.text,
|
||||
vmetadata = EXCLUDED.vmetadata
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": item["id"],
|
||||
"vector": vector,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ from open_webui.config import (
|
|||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
|
||||
|
||||
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
|
||||
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
|||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from typing import Optional, List
|
|||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import requests
|
|||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ def xml_element_contents_to_string(element: Element) -> str:
|
|||
|
||||
|
||||
def search_yandex(
|
||||
request: Request,
|
||||
yandex_search_url: str,
|
||||
yandex_search_api_key: str,
|
||||
yandex_search_config: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[List[str]] = None,
|
||||
user=None,
|
||||
request: Request,
|
||||
yandex_search_url: str,
|
||||
yandex_search_api_key: str,
|
||||
yandex_search_config: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[List[str]] = None,
|
||||
user=None,
|
||||
) -> List[SearchResult]:
|
||||
try:
|
||||
headers = {
|
||||
|
|
@ -73,7 +73,11 @@ def search_yandex(
|
|||
payload["groupSpec"]["docsInGroup"] = 1
|
||||
|
||||
response = requests.post(
|
||||
"https://searchapi.api.cloud.yandex.net/v2/web/search" if yandex_search_url == "" else yandex_search_url,
|
||||
(
|
||||
"https://searchapi.api.cloud.yandex.net/v2/web/search"
|
||||
if yandex_search_url == ""
|
||||
else yandex_search_url
|
||||
),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
|
|
@ -84,18 +88,28 @@ def search_yandex(
|
|||
if "rawData" not in response_body:
|
||||
raise Exception(f"No `rawData` in response body: {response_body}")
|
||||
|
||||
search_result_body_bytes = base64.decodebytes(bytes(response_body["rawData"], "utf-8"))
|
||||
search_result_body_bytes = base64.decodebytes(
|
||||
bytes(response_body["rawData"], "utf-8")
|
||||
)
|
||||
|
||||
doc_root = ET.parse(io.BytesIO(search_result_body_bytes))
|
||||
|
||||
results = []
|
||||
|
||||
for group in doc_root.findall("response/results/grouping/group"):
|
||||
results.append({
|
||||
"url": xml_element_contents_to_string(group.find("doc/url")).strip("\n"),
|
||||
"title": xml_element_contents_to_string(group.find("doc/title")).strip("\n"),
|
||||
"snippet": xml_element_contents_to_string(group.find("doc/passages/passage")),
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"url": xml_element_contents_to_string(group.find("doc/url")).strip(
|
||||
"\n"
|
||||
),
|
||||
"title": xml_element_contents_to_string(
|
||||
group.find("doc/title")
|
||||
).strip("\n"),
|
||||
"snippet": xml_element_contents_to_string(
|
||||
group.find("doc/passages/passage")
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
|
|
@ -140,7 +154,9 @@ if __name__ == "__main__":
|
|||
),
|
||||
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
|
||||
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
|
||||
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", "{\"query\": {\"searchType\": \"SEARCH_TYPE_COM\"}}"),
|
||||
os.environ.get(
|
||||
"YANDEX_WEB_SEARCH_CONFIG", '{"query": {"searchType": "SEARCH_TYPE_COM"}}'
|
||||
),
|
||||
"TOP movies of the past year",
|
||||
3,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -88,25 +88,29 @@ async def get_user_analytics(
|
|||
token_usage = ChatMessages.get_token_usage_by_user(
|
||||
start_date=start_date, end_date=end_date, db=db
|
||||
)
|
||||
|
||||
|
||||
# Get user info for top users
|
||||
top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]]
|
||||
top_user_ids = [
|
||||
uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]
|
||||
]
|
||||
user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)}
|
||||
|
||||
|
||||
users = []
|
||||
for user_id in top_user_ids:
|
||||
u = user_info.get(user_id)
|
||||
tokens = token_usage.get(user_id, {})
|
||||
users.append(UserAnalyticsEntry(
|
||||
user_id=user_id,
|
||||
name=u.name if u else None,
|
||||
email=u.email if u else None,
|
||||
count=counts[user_id],
|
||||
input_tokens=tokens.get("input_tokens", 0),
|
||||
output_tokens=tokens.get("output_tokens", 0),
|
||||
total_tokens=tokens.get("total_tokens", 0),
|
||||
))
|
||||
|
||||
users.append(
|
||||
UserAnalyticsEntry(
|
||||
user_id=user_id,
|
||||
name=u.name if u else None,
|
||||
email=u.email if u else None,
|
||||
count=counts[user_id],
|
||||
input_tokens=tokens.get("input_tokens", 0),
|
||||
output_tokens=tokens.get("output_tokens", 0),
|
||||
total_tokens=tokens.get("total_tokens", 0),
|
||||
)
|
||||
)
|
||||
|
||||
return UserAnalyticsResponse(users=users)
|
||||
|
||||
|
||||
|
|
@ -168,7 +172,7 @@ async def get_summary(
|
|||
chat_counts = ChatMessages.get_message_count_by_chat(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
|
||||
|
||||
return SummaryResponse(
|
||||
total_messages=sum(model_counts.values()),
|
||||
total_chats=len(chat_counts),
|
||||
|
|
@ -317,9 +321,7 @@ async def get_model_chats(
|
|||
if isinstance(content, str):
|
||||
first_message = content[:200]
|
||||
elif isinstance(content, list):
|
||||
text_parts = [
|
||||
b.get("text", "") for b in content if isinstance(b, dict)
|
||||
]
|
||||
text_parts = [b.get("text", "") for b in content if isinstance(b, dict)]
|
||||
first_message = " ".join(text_parts)[:200]
|
||||
|
||||
# Get user info
|
||||
|
|
@ -331,7 +333,6 @@ async def get_model_chats(
|
|||
# Timestamps from messages
|
||||
updated_at = max(m.created_at for m in messages) if messages else 0
|
||||
|
||||
|
||||
chats_data.append(
|
||||
ModelChatEntry(
|
||||
chat_id=chat_id,
|
||||
|
|
@ -387,24 +388,24 @@ async def get_model_overview(
|
|||
|
||||
# Get feedback history per day
|
||||
history_counts: dict[str, dict] = defaultdict(lambda: {"won": 0, "lost": 0})
|
||||
|
||||
|
||||
# Calculate start date for history
|
||||
now = datetime.now()
|
||||
start_dt = None
|
||||
if days > 0:
|
||||
start_dt = now - timedelta(days=days)
|
||||
|
||||
|
||||
for chat_id in chat_ids:
|
||||
feedbacks = Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db)
|
||||
for fb in feedbacks:
|
||||
if fb.data and "rating" in fb.data:
|
||||
rating = fb.data["rating"]
|
||||
fb_date = datetime.fromtimestamp(fb.created_at)
|
||||
|
||||
|
||||
# Filter by date range
|
||||
if start_dt and fb_date < start_dt:
|
||||
continue
|
||||
|
||||
|
||||
date_str = fb_date.strftime("%Y-%m-%d")
|
||||
if rating == 1:
|
||||
history_counts[date_str]["won"] += 1
|
||||
|
|
@ -423,15 +424,17 @@ async def get_model_overview(
|
|||
current = datetime.strptime(min_date, "%Y-%m-%d")
|
||||
else:
|
||||
current = now
|
||||
|
||||
|
||||
while current <= end_dt:
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
counts = history_counts.get(date_str, {"won": 0, "lost": 0})
|
||||
history.append(HistoryEntry(
|
||||
date=date_str,
|
||||
won=counts["won"],
|
||||
lost=counts["lost"],
|
||||
))
|
||||
history.append(
|
||||
HistoryEntry(
|
||||
date=date_str,
|
||||
won=counts["won"],
|
||||
lost=counts["lost"],
|
||||
)
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
|
||||
# Get chat tags
|
||||
|
|
|
|||
|
|
@ -53,11 +53,11 @@ from open_webui.env import (
|
|||
ENV,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
DEVICE_TYPE,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Constants
|
||||
|
|
@ -644,6 +644,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||
headers=headers,
|
||||
files={"file": (filename, open(file_path, "rb"))},
|
||||
data=payload,
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
|
|
@ -704,6 +705,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
|
|
@ -815,6 +817,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||
headers={
|
||||
"Ocp-Apim-Subscription-Key": api_key,
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
|
|
@ -954,6 +957,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
|
|
@ -997,6 +1001,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
|
|
@ -1240,7 +1245,8 @@ def get_available_models(request: Request) -> list[dict]:
|
|||
):
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models"
|
||||
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models",
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
|
@ -1286,7 +1292,8 @@ def get_available_voices(request) -> dict:
|
|||
):
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices"
|
||||
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices",
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
|
@ -1330,7 +1337,9 @@ def get_available_voices(request) -> dict:
|
|||
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
response = requests.get(
|
||||
url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST
|
||||
)
|
||||
response.raise_for_status()
|
||||
voices = response.json()
|
||||
|
||||
|
|
@ -1362,6 +1371,7 @@ def get_elevenlabs_voices(api_key: str) -> dict:
|
|||
"xi-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
voices_data = response.json()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import re
|
||||
import uuid
|
||||
import time
|
||||
|
|
@ -19,6 +20,7 @@ from open_webui.models.auths import (
|
|||
UpdatePasswordForm,
|
||||
)
|
||||
from open_webui.models.users import (
|
||||
UserModel,
|
||||
UserProfileImageResponse,
|
||||
Users,
|
||||
UpdateProfileForm,
|
||||
|
|
@ -98,7 +100,7 @@ def create_session_response(
|
|||
"""
|
||||
Create JWT token and build session response for a user.
|
||||
Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints.
|
||||
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
user: User object
|
||||
|
|
@ -376,7 +378,7 @@ async def ldap_auth(
|
|||
auto_bind="NONE",
|
||||
authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS",
|
||||
)
|
||||
if not connection_app.bind():
|
||||
if not await asyncio.to_thread(connection_app.bind):
|
||||
raise HTTPException(400, detail="Application account bind failed")
|
||||
|
||||
ENABLE_LDAP_GROUP_MANAGEMENT = (
|
||||
|
|
@ -397,7 +399,8 @@ async def ldap_auth(
|
|||
)
|
||||
log.info(f"LDAP search attributes: {search_attributes}")
|
||||
|
||||
search_success = connection_app.search(
|
||||
search_success = await asyncio.to_thread(
|
||||
connection_app.search,
|
||||
search_base=LDAP_SEARCH_BASE,
|
||||
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
|
||||
attributes=search_attributes,
|
||||
|
|
@ -501,7 +504,7 @@ async def ldap_auth(
|
|||
auto_bind="NONE",
|
||||
authentication="SIMPLE",
|
||||
)
|
||||
if not connection_user.bind():
|
||||
if not await asyncio.to_thread(connection_user.bind):
|
||||
raise HTTPException(400, "Authentication failed.")
|
||||
|
||||
user = Users.get_user_by_email(email, db=db)
|
||||
|
|
@ -558,7 +561,9 @@ async def ldap_auth(
|
|||
except Exception as e:
|
||||
log.error(f"Failed to sync groups for user {user.id}: {e}")
|
||||
|
||||
return create_session_response(request, user, db, response, set_cookie=True)
|
||||
return create_session_response(
|
||||
request, user, db, response, set_cookie=True
|
||||
)
|
||||
else:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
else:
|
||||
|
|
@ -601,10 +606,11 @@ async def signin(
|
|||
pass
|
||||
|
||||
if not Users.get_user_by_email(email.lower(), db=db):
|
||||
await signup(
|
||||
await signup_handler(
|
||||
request,
|
||||
response,
|
||||
SignupForm(email=email, password=str(uuid.uuid4()), name=name),
|
||||
email,
|
||||
str(uuid.uuid4()),
|
||||
name,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
|
@ -632,10 +638,11 @@ async def signin(
|
|||
if Users.has_users(db=db):
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
|
||||
|
||||
await signup(
|
||||
await signup_handler(
|
||||
request,
|
||||
response,
|
||||
SignupForm(email=admin_email, password=admin_password, name="User"),
|
||||
admin_email,
|
||||
admin_password,
|
||||
"User",
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
|
@ -677,6 +684,62 @@ async def signin(
|
|||
############################
|
||||
|
||||
|
||||
async def signup_handler(
|
||||
request: Request,
|
||||
email: str,
|
||||
password: str,
|
||||
name: str,
|
||||
profile_image_url: str = "/user.png",
|
||||
*,
|
||||
db: Session,
|
||||
) -> UserModel:
|
||||
"""
|
||||
Core user-creation logic shared by the signup endpoint and
|
||||
trusted-header / no-auth auto-registration flows.
|
||||
|
||||
Returns the newly created UserModel.
|
||||
Raises HTTPException on failure.
|
||||
"""
|
||||
has_users = Users.has_users(db=db)
|
||||
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
user = Auths.insert_new_auth(
|
||||
email=email.lower(),
|
||||
password=hashed,
|
||||
name=name,
|
||||
profile_image_url=profile_image_url,
|
||||
role=role,
|
||||
db=db,
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
"action": "signup",
|
||||
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
"user": user.model_dump_json(exclude_none=True),
|
||||
},
|
||||
)
|
||||
|
||||
if not has_users:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
apply_default_group_assignment(
|
||||
request.app.state.config.DEFAULT_GROUP_ID,
|
||||
user.id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/signup", response_model=SessionUserResponse)
|
||||
async def signup(
|
||||
request: Request,
|
||||
|
|
@ -715,44 +778,15 @@ async def signup(
|
|||
except Exception as e:
|
||||
raise HTTPException(400, detail=str(e))
|
||||
|
||||
hashed = get_password_hash(form_data.password)
|
||||
|
||||
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
|
||||
user = Auths.insert_new_auth(
|
||||
form_data.email.lower(),
|
||||
hashed,
|
||||
user = await signup_handler(
|
||||
request,
|
||||
form_data.email,
|
||||
form_data.password,
|
||||
form_data.name,
|
||||
form_data.profile_image_url,
|
||||
role,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if user:
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
"action": "signup",
|
||||
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
"user": user.model_dump_json(exclude_none=True),
|
||||
},
|
||||
)
|
||||
|
||||
if not has_users:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
apply_default_group_assignment(
|
||||
request.app.state.config.DEFAULT_GROUP_ID,
|
||||
user.id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return create_session_response(request, user, db, response, set_cookie=True)
|
||||
else:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
return create_session_response(request, user, db, response, set_cookie=True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as err:
|
||||
|
|
|
|||
|
|
@ -174,14 +174,7 @@ async def get_channels(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channels = Channels.get_channels_by_user_id(user.id, db=db)
|
||||
channel_list = []
|
||||
|
|
@ -254,15 +247,7 @@ async def get_dm_channel_by_user_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
check_channels_access(request, user)
|
||||
try:
|
||||
existing_channel = Channels.get_dm_channel_by_user_ids(
|
||||
[user.id, user_id], db=db
|
||||
|
|
@ -334,14 +319,7 @@ async def create_new_channel(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
if form_data.type not in ["group", "dm"] and user.role != "admin":
|
||||
# Only admins can create standard channels (joined by default)
|
||||
|
|
@ -659,15 +637,7 @@ async def add_members_by_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
check_channels_access(request, user)
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
|
|
@ -709,14 +679,7 @@ async def remove_members_by_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
|
|
@ -755,14 +718,7 @@ async def update_channel_by_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
|
|
@ -797,14 +753,7 @@ async def delete_channel_by_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
|
|
@ -1633,9 +1582,7 @@ async def update_message_by_id(
|
|||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
)
|
||||
and not channel_has_access(user.id, channel, permission="read", db=db)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ from fastapi.responses import FileResponse, StreamingResponse
|
|||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from pydantic import BaseModel, HttpUrl
|
|||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ import requests
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from open_webui.config import CACHE_DIR, IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN,
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
|
|
@ -199,9 +203,8 @@ async def update_config(
|
|||
|
||||
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE
|
||||
set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
|
||||
if (
|
||||
form_data.IMAGE_SIZE == "auto"
|
||||
and not re.match(IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL)
|
||||
if form_data.IMAGE_SIZE == "auto" and not re.match(
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -610,7 +613,10 @@ async def image_generations(
|
|||
),
|
||||
**(
|
||||
{}
|
||||
if re.match(IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN, request.app.state.config.IMAGE_GENERATION_MODEL)
|
||||
if re.match(
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL,
|
||||
)
|
||||
else {"response_format": "b64_json"}
|
||||
),
|
||||
**(
|
||||
|
|
@ -912,7 +918,9 @@ async def image_edits(
|
|||
form_data.image = await load_url_image(form_data.image)
|
||||
elif isinstance(form_data.image, list):
|
||||
# Load all images in parallel for better performance
|
||||
form_data.image = list(await asyncio.gather(*[load_url_image(img) for img in form_data.image]))
|
||||
form_data.image = list(
|
||||
await asyncio.gather(*[load_url_image(img) for img in form_data.image])
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
|
||||
|
||||
|
|
@ -947,7 +955,10 @@ async def image_edits(
|
|||
**({"size": size} if size else {}),
|
||||
**(
|
||||
{}
|
||||
if re.match(IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN, request.app.state.config.IMAGE_EDIT_MODEL)
|
||||
if re.match(
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
|
||||
request.app.state.config.IMAGE_EDIT_MODEL,
|
||||
)
|
||||
else {"response_format": "b64_json"}
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ from open_webui.models.access_grants import AccessGrants, has_public_read_access
|
|||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.models.models import Models, ModelForm
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -358,7 +357,7 @@ async def reindex_knowledge_base_metadata_embeddings(
|
|||
user=Depends(get_admin_user),
|
||||
):
|
||||
"""Batch embed all existing knowledge bases. Admin only.
|
||||
|
||||
|
||||
NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
This endpoint loops through ALL knowledge bases and calls embed_knowledge_base_metadata()
|
||||
for each one, making N external embedding API calls. Holding a session during
|
||||
|
|
@ -501,6 +500,53 @@ async def update_knowledge_by_id(
|
|||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateKnowledgeAccessById
|
||||
############################
|
||||
|
||||
|
||||
class KnowledgeAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/{id}/access/update", response_model=Optional[KnowledgeFilesResponse])
|
||||
async def update_knowledge_access_by_id(
|
||||
id: str,
|
||||
form_data: KnowledgeAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
|
||||
if not knowledge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**Knowledges.get_knowledge_by_id(id=id, db=db).model_dump(),
|
||||
files=Knowledges.get_file_metadatas_by_id(id, db=db),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetKnowledgeFilesById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -18,11 +18,6 @@ log = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ef")
|
||||
async def get_embeddings(request: Request):
|
||||
return {"result": await request.app.state.EMBEDDING_FUNCTION("hello world")}
|
||||
|
||||
|
||||
############################
|
||||
# GetMemories
|
||||
############################
|
||||
|
|
@ -165,7 +160,7 @@ async def reset_memory_from_vector_db(
|
|||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""Reset user's memory vector embeddings.
|
||||
|
||||
|
||||
CRITICAL: We intentionally do NOT use Depends(get_session) here.
|
||||
This endpoint generates embeddings for ALL user memories in parallel using
|
||||
asyncio.gather(). A user with 100 memories would trigger 100 embedding API
|
||||
|
|
|
|||
|
|
@ -253,12 +253,25 @@ async def import_models(
|
|||
try:
|
||||
data = form_data.models
|
||||
if isinstance(data, list):
|
||||
# Batch-fetch all existing models in one query to avoid N+1
|
||||
model_ids = [
|
||||
model_data.get("id")
|
||||
for model_data in data
|
||||
if model_data.get("id") and is_valid_model_id(model_data.get("id"))
|
||||
]
|
||||
existing_models = {
|
||||
model.id: model
|
||||
for model in (
|
||||
Models.get_models_by_ids(model_ids, db=db) if model_ids else []
|
||||
)
|
||||
}
|
||||
|
||||
for model_data in data:
|
||||
# Here, you can add logic to validate model_data if needed
|
||||
model_id = model_data.get("id")
|
||||
|
||||
if model_id and is_valid_model_id(model_id):
|
||||
existing_model = Models.get_model_by_id(model_id, db=db)
|
||||
existing_model = existing_models.get(model_id)
|
||||
if existing_model:
|
||||
# Update existing model
|
||||
model_data["meta"] = model_data.get("meta", {})
|
||||
|
|
@ -481,6 +494,52 @@ async def update_model_by_id(
|
|||
return model
|
||||
|
||||
|
||||
############################
|
||||
# UpdateModelAccessById
|
||||
############################
|
||||
|
||||
|
||||
class ModelAccessGrantsForm(BaseModel):
|
||||
id: str
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/model/access/update", response_model=Optional[ModelModel])
|
||||
async def update_model_access_by_id(
|
||||
form_data: ModelAccessGrantsForm,
|
||||
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_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
model.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants(
|
||||
"model", form_data.id, form_data.access_grants, db=db
|
||||
)
|
||||
|
||||
return Models.get_model_by_id(form_data.id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteModelById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -300,6 +300,56 @@ async def update_note_by_id(
|
|||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateNoteAccessById
|
||||
############################
|
||||
|
||||
|
||||
class NoteAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/{id}/access/update", response_model=Optional[NoteModel])
|
||||
async def update_note_access_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: NoteAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db)
|
||||
|
||||
return Notes.get_note_by_id(id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteNoteById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ from fastapi import (
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, validator
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from open_webui.internal.db import get_session
|
||||
|
|
@ -46,8 +46,11 @@ from open_webui.internal.db import get_session
|
|||
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.misc import (
|
||||
calculate_sha256,
|
||||
cleanup_response,
|
||||
stream_wrapper,
|
||||
)
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_ollama,
|
||||
|
|
@ -102,16 +105,6 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
|||
return None
|
||||
|
||||
|
||||
async def cleanup_response(
|
||||
response: Optional[aiohttp.ClientResponse],
|
||||
session: Optional[aiohttp.ClientSession],
|
||||
):
|
||||
if response:
|
||||
response.close()
|
||||
if session:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def send_post_request(
|
||||
url: str,
|
||||
payload: Union[str, bytes],
|
||||
|
|
@ -123,6 +116,7 @@ async def send_post_request(
|
|||
):
|
||||
|
||||
r = None
|
||||
streaming = False
|
||||
try:
|
||||
session = aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
|
|
@ -167,13 +161,11 @@ async def send_post_request(
|
|||
if content_type:
|
||||
response_headers["Content-Type"] = content_type
|
||||
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
r.content,
|
||||
stream_wrapper(r, session),
|
||||
status_code=r.status,
|
||||
headers=response_headers,
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
res = await r.json()
|
||||
|
|
@ -189,7 +181,7 @@ async def send_post_request(
|
|||
detail=detail if e else "Open WebUI: Server Connection Error",
|
||||
)
|
||||
finally:
|
||||
if not stream:
|
||||
if not streaming:
|
||||
await cleanup_response(r, session)
|
||||
|
||||
|
||||
|
|
@ -425,15 +417,20 @@ async def get_all_models(request: Request, user: UserModel = None):
|
|||
|
||||
async def get_filtered_models(models, user, db=None):
|
||||
# Filter models based on user access control
|
||||
model_ids = [model["model"] for model in models.get("models", [])]
|
||||
model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)}
|
||||
user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
|
||||
filtered_models = []
|
||||
for model in models.get("models", []):
|
||||
model_info = Models.get_model_by_id(model["model"], db=db)
|
||||
model_info = model_infos.get(model["model"])
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
|
@ -1024,13 +1021,13 @@ async def embed(
|
|||
log.info(f"generate_ollama_batch_embeddings {form_data}")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
model = form_data.model
|
||||
|
||||
if ":" not in model:
|
||||
model = f"{model}:latest"
|
||||
# Check if model is already in app state cache to avoid expensive get_all_models() call
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
if not models or model not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
if model in models:
|
||||
url_idx = random.choice(models[model]["urls"])
|
||||
|
|
@ -1109,13 +1106,13 @@ async def embeddings(
|
|||
log.info(f"generate_ollama_embeddings {form_data}")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
model = form_data.model
|
||||
|
||||
if ":" not in model:
|
||||
model = f"{model}:latest"
|
||||
# Check if model is already in app state cache to avoid expensive get_all_models() call
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
if not models or model not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
if model in models:
|
||||
url_idx = random.choice(models[model]["urls"])
|
||||
|
|
@ -1204,10 +1201,6 @@ async def generate_completion(
|
|||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
model = form_data.model
|
||||
|
||||
if ":" not in model:
|
||||
model = f"{model}:latest"
|
||||
|
||||
if model in models:
|
||||
url_idx = random.choice(models[model]["urls"])
|
||||
else:
|
||||
|
|
@ -1643,15 +1636,20 @@ async def get_openai_models(
|
|||
|
||||
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
# Filter models based on user access control
|
||||
model_ids = [model["id"] for model in models]
|
||||
model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)}
|
||||
user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
|
||||
filtered_models = []
|
||||
for model in models:
|
||||
model_info = Models.get_model_by_id(model["id"], db=db)
|
||||
model_info = model_infos.get(model["id"])
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import hashlib
|
|||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
|
|
@ -18,13 +19,14 @@ from fastapi.responses import (
|
|||
PlainTextResponse,
|
||||
)
|
||||
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.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
)
|
||||
|
|
@ -47,14 +49,15 @@ from open_webui.utils.payload import (
|
|||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.misc import (
|
||||
cleanup_response,
|
||||
convert_logit_bias_input_to_json,
|
||||
stream_chunks_handler,
|
||||
stream_wrapper,
|
||||
)
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -88,16 +91,6 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
|||
return None
|
||||
|
||||
|
||||
async def cleanup_response(
|
||||
response: Optional[aiohttp.ClientResponse],
|
||||
session: Optional[aiohttp.ClientSession],
|
||||
):
|
||||
if response:
|
||||
response.close()
|
||||
if session:
|
||||
await session.close()
|
||||
|
||||
|
||||
def openai_reasoning_model_handler(payload):
|
||||
"""
|
||||
Handle reasoning model specific parameters
|
||||
|
|
@ -348,37 +341,41 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
|||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
return []
|
||||
|
||||
# Cache config values locally to avoid repeated Redis lookups.
|
||||
# Each access to request.app.state.config.<KEY> triggers a Redis GET;
|
||||
# caching here avoids hundreds of redundant round-trips.
|
||||
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
|
||||
api_keys = list(request.app.state.config.OPENAI_API_KEYS)
|
||||
api_configs = request.app.state.config.OPENAI_API_CONFIGS
|
||||
|
||||
# Check if API KEYS length is same than API URLS length
|
||||
num_urls = len(request.app.state.config.OPENAI_API_BASE_URLS)
|
||||
num_keys = len(request.app.state.config.OPENAI_API_KEYS)
|
||||
num_urls = len(api_base_urls)
|
||||
num_keys = len(api_keys)
|
||||
|
||||
if num_keys != num_urls:
|
||||
# if there are more keys than urls, remove the extra keys
|
||||
if num_keys > num_urls:
|
||||
new_keys = request.app.state.config.OPENAI_API_KEYS[:num_urls]
|
||||
request.app.state.config.OPENAI_API_KEYS = new_keys
|
||||
api_keys = api_keys[:num_urls]
|
||||
request.app.state.config.OPENAI_API_KEYS = api_keys
|
||||
# if there are more urls than keys, add empty keys
|
||||
else:
|
||||
request.app.state.config.OPENAI_API_KEYS += [""] * (num_urls - num_keys)
|
||||
api_keys += [""] * (num_urls - num_keys)
|
||||
request.app.state.config.OPENAI_API_KEYS = api_keys
|
||||
|
||||
request_tasks = []
|
||||
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
|
||||
if (str(idx) not in request.app.state.config.OPENAI_API_CONFIGS) and (
|
||||
url not in request.app.state.config.OPENAI_API_CONFIGS # Legacy support
|
||||
):
|
||||
for idx, url in enumerate(api_base_urls):
|
||||
if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support
|
||||
request_tasks.append(
|
||||
send_get_request(
|
||||
f"{url}/models",
|
||||
request.app.state.config.OPENAI_API_KEYS[idx],
|
||||
api_keys[idx],
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
else:
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
api_config = api_configs.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
api_configs.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
enable = api_config.get("enable", True)
|
||||
|
|
@ -389,7 +386,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
|||
request_tasks.append(
|
||||
send_get_request(
|
||||
f"{url}/models",
|
||||
request.app.state.config.OPENAI_API_KEYS[idx],
|
||||
api_keys[idx],
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
|
|
@ -418,12 +415,10 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
|||
|
||||
for idx, response in enumerate(responses):
|
||||
if response:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
url = api_base_urls[idx]
|
||||
api_config = api_configs.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
api_configs.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
connection_type = api_config.get("connection_type", "external")
|
||||
|
|
@ -459,15 +454,20 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
|||
|
||||
async def get_filtered_models(models, user, db=None):
|
||||
# Filter models based on user access control
|
||||
model_ids = [model["id"] for model in models.get("data", [])]
|
||||
model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)}
|
||||
user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
|
||||
filtered_models = []
|
||||
for model in models.get("data", []):
|
||||
model_info = Models.get_model_by_id(model["id"], db=db)
|
||||
model_info = model_infos.get(model["id"])
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
|
@ -484,6 +484,10 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
|||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
return {"data": []}
|
||||
|
||||
# Cache config value locally to avoid repeated Redis lookups inside
|
||||
# the nested loop in get_merged_models (one GET per model otherwise).
|
||||
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
|
||||
|
||||
responses = await get_all_models_responses(request, user=user)
|
||||
|
||||
def extract_data(response):
|
||||
|
|
@ -517,10 +521,10 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
|||
for model in model_list:
|
||||
model_id = model.get("id") or model.get("name")
|
||||
|
||||
if (
|
||||
"api.openai.com"
|
||||
in request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
and not is_supported_openai_models(model_id)
|
||||
base_url = api_base_urls[idx]
|
||||
hostname = urlparse(base_url).hostname if base_url else None
|
||||
if hostname == "api.openai.com" and not is_supported_openai_models(
|
||||
model_id
|
||||
):
|
||||
# Skip unwanted OpenAI models
|
||||
continue
|
||||
|
|
@ -805,67 +809,77 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
|
|||
def convert_to_responses_payload(payload: dict) -> dict:
|
||||
"""
|
||||
Convert Chat Completions payload to Responses API format.
|
||||
|
||||
|
||||
Chat Completions: { messages: [{role, content}], ... }
|
||||
Responses API: { input: [{type: "message", role, content: [...]}], instructions: "system" }
|
||||
"""
|
||||
messages = payload.pop("messages", [])
|
||||
|
||||
|
||||
system_content = ""
|
||||
input_items = []
|
||||
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
|
||||
|
||||
# Check for stored output items (from previous Responses API turn)
|
||||
stored_output = msg.get("output")
|
||||
if stored_output and isinstance(stored_output, list):
|
||||
input_items.extend(stored_output)
|
||||
continue
|
||||
|
||||
|
||||
if role == "system":
|
||||
if isinstance(content, str):
|
||||
system_content = content
|
||||
elif isinstance(content, list):
|
||||
system_content = "\n".join(p.get("text", "") for p in content if p.get("type") == "text")
|
||||
system_content = "\n".join(
|
||||
p.get("text", "") for p in content if p.get("type") == "text"
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
# Convert content format
|
||||
text_type = "output_text" if role == "assistant" else "input_text"
|
||||
|
||||
|
||||
if isinstance(content, str):
|
||||
content_parts = [{"type": text_type, "text": content}]
|
||||
elif isinstance(content, list):
|
||||
content_parts = []
|
||||
for part in content:
|
||||
if part.get("type") == "text":
|
||||
content_parts.append({"type": text_type, "text": part.get("text", "")})
|
||||
content_parts.append(
|
||||
{"type": text_type, "text": part.get("text", "")}
|
||||
)
|
||||
elif part.get("type") == "image_url":
|
||||
url_data = part.get("image_url", {})
|
||||
url = url_data.get("url", "") if isinstance(url_data, dict) else url_data
|
||||
url = (
|
||||
url_data.get("url", "")
|
||||
if isinstance(url_data, dict)
|
||||
else url_data
|
||||
)
|
||||
content_parts.append({"type": "input_image", "image_url": url})
|
||||
else:
|
||||
content_parts = [{"type": text_type, "text": str(content)}]
|
||||
|
||||
input_items.append({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": content_parts
|
||||
})
|
||||
|
||||
|
||||
input_items.append({"type": "message", "role": role, "content": content_parts})
|
||||
|
||||
responses_payload = {**payload, "input": input_items}
|
||||
|
||||
|
||||
if system_content:
|
||||
responses_payload["instructions"] = system_content
|
||||
|
||||
|
||||
if "max_tokens" in responses_payload:
|
||||
responses_payload["max_output_tokens"] = responses_payload.pop("max_tokens")
|
||||
|
||||
|
||||
# Remove Chat Completions-only parameters not supported by the Responses API
|
||||
for unsupported_key in ("stream_options", "logit_bias", "frequency_penalty", "presence_penalty", "stop"):
|
||||
for unsupported_key in (
|
||||
"stream_options",
|
||||
"logit_bias",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"stop",
|
||||
):
|
||||
responses_payload.pop(unsupported_key, None)
|
||||
|
||||
|
||||
# Convert Chat Completions tools format to Responses API format
|
||||
# Chat Completions: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
|
||||
# Responses API: {"type": "function", "name": ..., "description": ..., "parameters": ...}
|
||||
|
|
@ -888,9 +902,8 @@ def convert_to_responses_payload(payload: dict) -> dict:
|
|||
# Already in correct format or unknown format, pass through
|
||||
converted_tools.append(tool)
|
||||
responses_payload["tools"] = converted_tools
|
||||
|
||||
return responses_payload
|
||||
|
||||
return responses_payload
|
||||
|
||||
|
||||
def convert_responses_result(response: dict) -> dict:
|
||||
|
|
@ -967,8 +980,13 @@ async def generate_chat_completion(
|
|||
detail="Model not found",
|
||||
)
|
||||
|
||||
await get_all_models(request, user=user)
|
||||
model = request.app.state.OPENAI_MODELS.get(model_id)
|
||||
# Check if model is already in app state cache to avoid expensive get_all_models() call
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
model = models.get(model_id)
|
||||
|
||||
if model:
|
||||
idx = model["urlIdx"]
|
||||
else:
|
||||
|
|
@ -1036,7 +1054,7 @@ async def generate_chat_completion(
|
|||
headers["api-key"] = key
|
||||
|
||||
headers["api-version"] = api_version
|
||||
|
||||
|
||||
if is_responses:
|
||||
payload = convert_to_responses_payload(payload)
|
||||
request_url = f"{request_url}/responses?api-version={api_version}"
|
||||
|
|
@ -1074,12 +1092,9 @@ async def generate_chat_completion(
|
|||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_chunks_handler(r.content),
|
||||
stream_wrapper(r, session, stream_chunks_handler),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
@ -1127,9 +1142,12 @@ async def embeddings(request: Request, form_data: dict, user):
|
|||
# Prepare payload/body
|
||||
body = json.dumps(form_data)
|
||||
# Find correct backend url/key based on model
|
||||
await get_all_models(request, user=user)
|
||||
model_id = form_data.get("model")
|
||||
# Check if model is already in app state cache to avoid expensive get_all_models() call
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if model_id in models:
|
||||
idx = models[model_id]["urlIdx"]
|
||||
|
||||
|
|
@ -1160,12 +1178,9 @@ async def embeddings(request: Request, form_data: dict, user):
|
|||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
r.content,
|
||||
stream_wrapper(r, session),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
@ -1252,12 +1267,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
|||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
r.content,
|
||||
stream_wrapper(r, session),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import aiohttp
|
|||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import FileResponse
|
||||
from typing import Optional
|
||||
|
|
@ -217,7 +216,7 @@ async def upload_pipeline(
|
|||
os.makedirs(upload_folder, exist_ok=True)
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
# Save the uploaded file
|
||||
with open(file_path, "wb") as buffer:
|
||||
|
|
@ -226,16 +225,25 @@ async def upload_pipeline(
|
|||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": f}
|
||||
r = requests.post(
|
||||
f"{url}/pipelines/upload",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
files=files,
|
||||
headers = {"Authorization": f"Bearer {key}"}
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field(
|
||||
"file",
|
||||
open(file_path, "rb"),
|
||||
filename=filename,
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with session.post(
|
||||
f"{url}/pipelines/upload",
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
|
|
@ -244,10 +252,10 @@ async def upload_pipeline(
|
|||
|
||||
detail = None
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
if r is not None:
|
||||
status_code = r.status_code
|
||||
if response is not None:
|
||||
status_code = response.status
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
|
|
@ -272,21 +280,22 @@ class AddPipelineForm(BaseModel):
|
|||
async def add_pipeline(
|
||||
request: Request, form_data: AddPipelineForm, user=Depends(get_admin_user)
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
urlIdx = form_data.urlIdx
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.post(
|
||||
f"{url}/pipelines/add",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={"url": form_data.url},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
f"{url}/pipelines/add",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={"url": form_data.url},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
|
|
@ -294,16 +303,18 @@ async def add_pipeline(
|
|||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
|
|
@ -317,21 +328,22 @@ class DeletePipelineForm(BaseModel):
|
|||
async def delete_pipeline(
|
||||
request: Request, form_data: DeletePipelineForm, user=Depends(get_admin_user)
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
urlIdx = form_data.urlIdx
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.delete(
|
||||
f"{url}/pipelines/delete",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={"id": form_data.id},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.delete(
|
||||
f"{url}/pipelines/delete",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={"id": form_data.id},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
|
|
@ -339,16 +351,18 @@ async def delete_pipeline(
|
|||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
|
|
@ -357,15 +371,19 @@ async def delete_pipeline(
|
|||
async def get_pipelines(
|
||||
request: Request, urlIdx: Optional[int] = None, user=Depends(get_admin_user)
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.get(f"{url}/pipelines", headers={"Authorization": f"Bearer {key}"})
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
f"{url}/pipelines",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
|
|
@ -373,16 +391,18 @@ async def get_pipelines(
|
|||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
|
|
@ -394,17 +414,19 @@ async def get_pipeline_valves(
|
|||
pipeline_id: str,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.get(
|
||||
f"{url}/{pipeline_id}/valves", headers={"Authorization": f"Bearer {key}"}
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
f"{url}/{pipeline_id}/valves",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
|
|
@ -412,16 +434,18 @@ async def get_pipeline_valves(
|
|||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
|
|
@ -433,18 +457,19 @@ async def get_pipeline_valves_spec(
|
|||
pipeline_id: str,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.get(
|
||||
f"{url}/{pipeline_id}/valves/spec",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
f"{url}/{pipeline_id}/valves/spec",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
|
|
@ -452,16 +477,18 @@ async def get_pipeline_valves_spec(
|
|||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
|
|
@ -474,19 +501,20 @@ async def update_pipeline_valves(
|
|||
form_data: dict,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.post(
|
||||
f"{url}/{pipeline_id}/valves/update",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={**form_data},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
f"{url}/{pipeline_id}/valves/update",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={**form_data},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
|
|
@ -495,15 +523,17 @@ async def update_pipeline_valves(
|
|||
|
||||
detail = None
|
||||
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ async def get_prompt_list(
|
|||
|
||||
filter["user_id"] = user.id
|
||||
|
||||
result = Prompts.search_prompts(user.id, filter=filter, skip=skip, limit=limit, db=db)
|
||||
result = Prompts.search_prompts(
|
||||
user.id, filter=filter, skip=skip, limit=limit, db=db
|
||||
)
|
||||
|
||||
return PromptAccessListResponse(
|
||||
items=[
|
||||
|
|
@ -313,9 +315,7 @@ async def update_prompt_by_id(
|
|||
)
|
||||
|
||||
# Use the ID from the found prompt
|
||||
updated_prompt = Prompts.update_prompt_by_id(
|
||||
prompt.id, form_data, user.id, db=db
|
||||
)
|
||||
updated_prompt = Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db)
|
||||
if updated_prompt:
|
||||
return updated_prompt
|
||||
else:
|
||||
|
|
@ -425,6 +425,50 @@ async def set_prompt_version(
|
|||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdatePromptAccessById
|
||||
############################
|
||||
|
||||
|
||||
class PromptAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/id/{prompt_id}/access/update", response_model=Optional[PromptModel])
|
||||
async def update_prompt_access_by_id(
|
||||
prompt_id: str,
|
||||
form_data: PromptAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db)
|
||||
|
||||
return Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# DeletePromptById
|
||||
############################
|
||||
|
|
@ -476,7 +520,7 @@ async def get_prompt_history(
|
|||
):
|
||||
"""Get version history for a prompt."""
|
||||
PAGE_SIZE = 20
|
||||
|
||||
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
|
|
@ -508,9 +552,7 @@ async def get_prompt_history(
|
|||
return history
|
||||
|
||||
|
||||
@router.get(
|
||||
"/id/{prompt_id}/history/{history_id}", response_model=PromptHistoryModel
|
||||
)
|
||||
@router.get("/id/{prompt_id}/history/{history_id}", response_model=PromptHistoryModel)
|
||||
async def get_prompt_history_entry(
|
||||
prompt_id: str,
|
||||
history_id: str,
|
||||
|
|
@ -553,9 +595,7 @@ async def get_prompt_history_entry(
|
|||
return history_entry
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/id/{prompt_id}/history/{history_id}", response_model=bool
|
||||
)
|
||||
@router.delete("/id/{prompt_id}/history/{history_id}", response_model=bool)
|
||||
async def delete_prompt_history_entry(
|
||||
prompt_id: str,
|
||||
history_id: str,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ from open_webui.config import (
|
|||
from open_webui.env import (
|
||||
DEVICE_TYPE,
|
||||
DOCKER,
|
||||
RAG_EMBEDDING_TIMEOUT,
|
||||
SENTENCE_TRANSFORMERS_BACKEND,
|
||||
SENTENCE_TRANSFORMERS_MODEL_KWARGS,
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
|
||||
|
|
@ -1606,14 +1607,19 @@ def save_docs_to_vector_db(
|
|||
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
)
|
||||
|
||||
# Run async embedding in sync context
|
||||
embeddings = asyncio.run(
|
||||
# Run async embedding in sync context using the main event loop
|
||||
# This allows the main loop to stay responsive to health checks during long operations
|
||||
embedding_timeout = RAG_EMBEDDING_TIMEOUT
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
embedding_function(
|
||||
list(map(lambda x: x.replace("\n", " "), texts)),
|
||||
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
|
||||
user=user,
|
||||
)
|
||||
),
|
||||
request.app.state.main_loop,
|
||||
)
|
||||
embeddings = future.result(timeout=embedding_timeout)
|
||||
log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items")
|
||||
|
||||
items = [
|
||||
|
|
|
|||
427
backend/open_webui/routers/skills.py
Normal file
427
backend/open_webui/routers/skills.py
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.models.groups import Groups
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from open_webui.internal.db import get_session
|
||||
from open_webui.models.skills import (
|
||||
SkillForm,
|
||||
SkillModel,
|
||||
SkillResponse,
|
||||
SkillUserResponse,
|
||||
SkillAccessResponse,
|
||||
SkillAccessListResponse,
|
||||
Skills,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
############################
|
||||
# GetSkills
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SkillUserResponse])
|
||||
async def get_skills(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
skills = Skills.get_skills(db=db)
|
||||
else:
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
all_skills = Skills.get_skills(db=db)
|
||||
skills = [
|
||||
skill
|
||||
for skill in all_skills
|
||||
if skill.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
return skills
|
||||
|
||||
|
||||
############################
|
||||
# GetSkillList
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/list", response_model=SkillAccessListResponse)
|
||||
async def get_skill_list(
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
limit = PAGE_ITEM_COUNT
|
||||
|
||||
page = max(1, page)
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if view_option:
|
||||
filter["view_option"] = view_option
|
||||
|
||||
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
if groups:
|
||||
filter["group_ids"] = [group.id for group in groups]
|
||||
|
||||
filter["user_id"] = user.id
|
||||
|
||||
result = Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db)
|
||||
|
||||
return SkillAccessListResponse(
|
||||
items=[
|
||||
SkillAccessResponse(
|
||||
**skill.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == skill.user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
for skill in result.items
|
||||
],
|
||||
total=result.total,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ExportSkills
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/export", response_model=list[SkillModel])
|
||||
async def export_skills(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id,
|
||||
"workspace.skills",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return Skills.get_skills(db=db)
|
||||
else:
|
||||
return Skills.get_skills_by_user_id(user.id, "read", db=db)
|
||||
|
||||
|
||||
############################
|
||||
# CreateNewSkill
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/create", response_model=Optional[SkillResponse])
|
||||
async def create_new_skill(
|
||||
request: Request,
|
||||
form_data: SkillForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "workspace.skills", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
form_data.id = form_data.id.lower().replace(" ", "-")
|
||||
|
||||
existing = Skills.get_skill_by_id(form_data.id, db=db)
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ID_TAKEN,
|
||||
)
|
||||
|
||||
try:
|
||||
skill = Skills.insert_new_skill(user.id, form_data, db=db)
|
||||
if skill:
|
||||
return skill
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error creating skill"),
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to create skill: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(str(e)),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetSkillById
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/id/{id}", response_model=Optional[SkillAccessResponse])
|
||||
async def get_skill_by_id(
|
||||
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
|
||||
if skill:
|
||||
if (
|
||||
user.role == "admin"
|
||||
or skill.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return SkillAccessResponse(
|
||||
**skill.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == skill.user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateSkillById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/id/{id}/update", response_model=Optional[SkillModel])
|
||||
async def update_skill_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: SkillForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
if not skill:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
skill.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
try:
|
||||
updated = {
|
||||
**form_data.model_dump(exclude={"id"}),
|
||||
}
|
||||
|
||||
skill = Skills.update_skill_by_id(id, updated, db=db)
|
||||
|
||||
if skill:
|
||||
return skill
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error updating skill"),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(str(e)),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateSkillAccessById
|
||||
############################
|
||||
|
||||
|
||||
class SkillAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/id/{id}/access/update", response_model=Optional[SkillModel])
|
||||
async def update_skill_access_by_id(
|
||||
id: str,
|
||||
form_data: SkillAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
if not skill:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
skill.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db)
|
||||
|
||||
return Skills.get_skill_by_id(id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# ToggleSkillById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/id/{id}/toggle", response_model=Optional[SkillModel])
|
||||
async def toggle_skill_by_id(
|
||||
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
if skill:
|
||||
if (
|
||||
user.role == "admin"
|
||||
or skill.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
skill = Skills.toggle_skill_by_id(id, db=db)
|
||||
|
||||
if skill:
|
||||
return skill
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error toggling skill"),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteSkillById
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/id/{id}/delete", response_model=bool)
|
||||
async def delete_skill_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
if not skill:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
skill.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
result = Skills.delete_skill_by_id(id, db=db)
|
||||
return result
|
||||
|
|
@ -36,7 +36,6 @@ from open_webui.config import (
|
|||
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ from open_webui.utils.tools import get_tool_servers
|
|||
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -77,12 +76,21 @@ async def get_tools(
|
|||
)
|
||||
|
||||
# OpenAPI Tool Servers
|
||||
server_access_grants = {}
|
||||
for server in await get_tool_servers(request):
|
||||
connection = request.app.state.config.TOOL_SERVER_CONNECTIONS[
|
||||
server.get("idx", 0)
|
||||
]
|
||||
server_config = connection.get("config", {})
|
||||
|
||||
server_id = f"server:{server.get('id')}"
|
||||
server_access_grants[server_id] = server_config.get("access_grants", [])
|
||||
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
"id": f"server:{server.get('id')}",
|
||||
"user_id": f"server:{server.get('id')}",
|
||||
"id": server_id,
|
||||
"user_id": server_id,
|
||||
"name": server.get("openapi", {})
|
||||
.get("info", {})
|
||||
.get("title", "Tool Server"),
|
||||
|
|
@ -91,11 +99,6 @@ async def get_tools(
|
|||
.get("info", {})
|
||||
.get("description", ""),
|
||||
},
|
||||
"access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[
|
||||
server.get("idx", 0)
|
||||
]
|
||||
.get("config", {})
|
||||
.get("access_control", None),
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
|
|
@ -119,20 +122,22 @@ async def get_tools(
|
|||
)
|
||||
)
|
||||
|
||||
server_config = server.get("config", {})
|
||||
|
||||
tool_id = f"server:mcp:{server.get('info', {}).get('id')}"
|
||||
server_access_grants[tool_id] = server_config.get("access_grants", [])
|
||||
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
"id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"id": tool_id,
|
||||
"user_id": tool_id,
|
||||
"name": server.get("info", {}).get("name", "MCP Tool Server"),
|
||||
"meta": {
|
||||
"description": server.get("info", {}).get(
|
||||
"description", ""
|
||||
),
|
||||
},
|
||||
"access_control": server.get("config", {}).get(
|
||||
"access_control", None
|
||||
),
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
**(
|
||||
|
|
@ -161,7 +166,7 @@ async def get_tools(
|
|||
has_access(
|
||||
user.id,
|
||||
"read",
|
||||
getattr(tool, "access_control", None),
|
||||
server_access_grants.get(str(tool.id), []),
|
||||
user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
|
@ -510,6 +515,50 @@ async def update_tools_by_id(
|
|||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateToolAccessById
|
||||
############################
|
||||
|
||||
|
||||
class ToolAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/id/{id}/access/update", response_model=Optional[ToolModel])
|
||||
async def update_tool_access_by_id(
|
||||
id: str,
|
||||
form_data: ToolAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
tools = Tools.get_tool_by_id(id, db=db)
|
||||
if not tools:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db)
|
||||
|
||||
return Tools.get_tool_by_id(id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteToolsById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ from open_webui.utils.auth import (
|
|||
)
|
||||
from open_webui.utils.access_control import get_permissions, has_permission
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -173,6 +172,7 @@ class WorkspacePermissions(BaseModel):
|
|||
knowledge: bool = False
|
||||
prompts: bool = False
|
||||
tools: bool = False
|
||||
skills: bool = False
|
||||
models_import: bool = False
|
||||
models_export: bool = False
|
||||
prompts_import: bool = False
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from open_webui.utils.pdf_generator import PDFGenerator
|
|||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from open_webui.utils.auth import decode_token
|
|||
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
|
||||
from open_webui.tasks import create_task, stop_item_tasks
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
|
||||
|
|
@ -49,7 +50,6 @@ from open_webui.env import (
|
|||
GLOBAL_LOG_LEVEL,
|
||||
)
|
||||
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -346,11 +346,12 @@ async def user_join(sid, data):
|
|||
|
||||
await sio.enter_room(sid, f"user:{user.id}")
|
||||
|
||||
# Join all the channels
|
||||
channels = Channels.get_channels_by_user_id(user.id)
|
||||
log.debug(f"{channels=}")
|
||||
for channel in channels:
|
||||
await sio.enter_room(sid, f"channel:{channel.id}")
|
||||
# Join all the channels only if user has channels permission
|
||||
if user.role == "admin" or has_permission(user.id, "features.channels"):
|
||||
channels = Channels.get_channels_by_user_id(user.id)
|
||||
log.debug(f"{channels=}")
|
||||
for channel in channels:
|
||||
await sio.enter_room(sid, f"channel:{channel.id}")
|
||||
|
||||
return {"id": user.id, "name": user.name}
|
||||
|
||||
|
|
@ -376,11 +377,12 @@ async def join_channel(sid, data):
|
|||
if not user:
|
||||
return
|
||||
|
||||
# Join all the channels
|
||||
channels = Channels.get_channels_by_user_id(user.id)
|
||||
log.debug(f"{channels=}")
|
||||
for channel in channels:
|
||||
await sio.enter_room(sid, f"channel:{channel.id}")
|
||||
# Join all the channels only if user has channels permission
|
||||
if user.role == "admin" or has_permission(user.id, "features.channels"):
|
||||
channels = Channels.get_channels_by_user_id(user.id)
|
||||
log.debug(f"{channels=}")
|
||||
for channel in channels:
|
||||
await sio.enter_room(sid, f"channel:{channel.id}")
|
||||
|
||||
|
||||
@sio.on("join-note")
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ from azure.identity import DefaultAzureCredential
|
|||
from azure.storage.blob import BlobServiceClient
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from typing import Dict, List, Optional
|
|||
|
||||
from open_webui.env import REDIS_KEY_PREFIX
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A dictionary to keep track of active tasks
|
||||
|
|
@ -74,7 +73,13 @@ async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]:
|
|||
|
||||
|
||||
async def redis_send_command(redis: Redis, command: dict):
|
||||
await redis.publish(REDIS_PUBSUB_CHANNEL, json.dumps(command))
|
||||
command_json = json.dumps(command)
|
||||
# RedisCluster doesn't expose publish() directly, but the
|
||||
# PUBLISH command broadcasts across all cluster nodes server-side.
|
||||
if hasattr(redis, "nodes_manager"):
|
||||
await redis.execute_command("PUBLISH", REDIS_PUBSUB_CHANNEL, command_json)
|
||||
else:
|
||||
await redis.publish(REDIS_PUBSUB_CHANNEL, command_json)
|
||||
|
||||
|
||||
async def cleanup_task(redis, task_id: str, id=None):
|
||||
|
|
|
|||
|
|
@ -380,8 +380,8 @@ async def execute_code(
|
|||
# Add import blocking code if there are blocked modules
|
||||
if CODE_INTERPRETER_BLOCKED_MODULES:
|
||||
import textwrap
|
||||
blocking_code = textwrap.dedent(
|
||||
f"""
|
||||
|
||||
blocking_code = textwrap.dedent(f"""
|
||||
import builtins
|
||||
|
||||
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
|
||||
|
|
@ -397,15 +397,20 @@ async def execute_code(
|
|||
return _real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
builtins.__import__ = restricted_import
|
||||
"""
|
||||
)
|
||||
""")
|
||||
code = blocking_code + "\n" + code
|
||||
|
||||
engine = getattr(__request__.app.state.config, "CODE_INTERPRETER_ENGINE", "pyodide")
|
||||
engine = getattr(
|
||||
__request__.app.state.config, "CODE_INTERPRETER_ENGINE", "pyodide"
|
||||
)
|
||||
if engine == "pyodide":
|
||||
# Execute via frontend pyodide using bidirectional event call
|
||||
if __event_call__ is None:
|
||||
return json.dumps({"error": "Event call not available. WebSocket connection required for pyodide execution."})
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "Event call not available. WebSocket connection required for pyodide execution."
|
||||
}
|
||||
)
|
||||
|
||||
output = await __event_call__(
|
||||
{
|
||||
|
|
@ -413,7 +418,9 @@ async def execute_code(
|
|||
"data": {
|
||||
"id": str(uuid4()),
|
||||
"code": code,
|
||||
"session_id": __metadata__.get("session_id") if __metadata__ else None,
|
||||
"session_id": (
|
||||
__metadata__.get("session_id") if __metadata__ else None
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -436,12 +443,14 @@ async def execute_code(
|
|||
code,
|
||||
(
|
||||
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
|
||||
if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == "token"
|
||||
if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH
|
||||
== "token"
|
||||
else None
|
||||
),
|
||||
(
|
||||
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
|
||||
if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == "password"
|
||||
if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH
|
||||
== "password"
|
||||
else None
|
||||
),
|
||||
__request__.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
|
||||
|
|
@ -1881,3 +1890,65 @@ async def query_knowledge_bases(
|
|||
except Exception as e:
|
||||
log.exception(f"query_knowledge_bases error: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SKILLS TOOLS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def view_skill(
|
||||
name: str,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Load the full instructions of a skill by its name from the available skills manifest.
|
||||
Use this when you need detailed instructions for a skill listed in <available_skills>.
|
||||
|
||||
:param name: The name of the skill to load (as shown in the manifest)
|
||||
:return: The full skill instructions as markdown content
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({"error": "Request context not available"})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({"error": "User context not available"})
|
||||
|
||||
try:
|
||||
from open_webui.models.skills import Skills
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_id = __user__.get("id")
|
||||
|
||||
# Direct DB lookup by unique name
|
||||
skill = Skills.get_skill_by_name(name)
|
||||
|
||||
if not skill or not skill.is_active:
|
||||
return json.dumps({"error": f"Skill '{name}' not found"})
|
||||
|
||||
# Check user access
|
||||
user_role = __user__.get("role", "user")
|
||||
if user_role != "admin" and skill.user_id != user_id:
|
||||
user_group_ids = [
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id)
|
||||
]
|
||||
if not AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="read",
|
||||
user_group_ids=set(user_group_ids),
|
||||
):
|
||||
return json.dumps({"error": "Access denied"})
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"name": skill.name,
|
||||
"content": skill.content,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"view_skill error: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
|
|
|||
|
|
@ -107,71 +107,90 @@ def has_permission(
|
|||
return get_permission(default_permissions, permission_hierarchy)
|
||||
|
||||
|
||||
def get_permitted_group_and_user_ids(
|
||||
type: str = "write", access_control: Optional[dict] = None
|
||||
) -> Union[Dict[str, List[str]], None]:
|
||||
if access_control is None:
|
||||
return None
|
||||
|
||||
permission_access = access_control.get(type, {})
|
||||
permitted_group_ids = permission_access.get("group_ids", [])
|
||||
permitted_user_ids = permission_access.get("user_ids", [])
|
||||
|
||||
return {
|
||||
"group_ids": permitted_group_ids,
|
||||
"user_ids": permitted_user_ids,
|
||||
}
|
||||
|
||||
|
||||
def has_access(
|
||||
user_id: str,
|
||||
type: str = "write",
|
||||
access_control: Optional[dict] = None,
|
||||
permission: str = "read",
|
||||
access_grants: Optional[list] = None,
|
||||
user_group_ids: Optional[Set[str]] = None,
|
||||
strict: bool = True,
|
||||
db: Optional[Any] = None,
|
||||
) -> bool:
|
||||
if access_control is None:
|
||||
if strict:
|
||||
return type == "read"
|
||||
else:
|
||||
return True
|
||||
"""
|
||||
Check if a user has the specified permission using an in-memory access_grants list.
|
||||
|
||||
Used for config-driven resources (arena models, tool servers) that store
|
||||
access control as JSON in PersistentConfig rather than in the access_grant DB table.
|
||||
|
||||
Semantics:
|
||||
- None or [] → private (owner-only, deny all)
|
||||
- [{"principal_type": "user", "principal_id": "*", "permission": "read"}] → public read
|
||||
- Specific grants → check user/group membership
|
||||
"""
|
||||
if not access_grants:
|
||||
return False
|
||||
|
||||
if user_group_ids is None:
|
||||
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)
|
||||
if permitted_ids is None:
|
||||
return False
|
||||
for grant in access_grants:
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
if grant.get("permission") != permission:
|
||||
continue
|
||||
principal_type = grant.get("principal_type")
|
||||
principal_id = grant.get("principal_id")
|
||||
if principal_type == "user" and (
|
||||
principal_id == "*" or principal_id == user_id
|
||||
):
|
||||
return True
|
||||
if (
|
||||
principal_type == "group"
|
||||
and user_group_ids
|
||||
and principal_id in user_group_ids
|
||||
):
|
||||
return True
|
||||
|
||||
permitted_group_ids = permitted_ids.get("group_ids", [])
|
||||
permitted_user_ids = permitted_ids.get("user_ids", [])
|
||||
|
||||
return user_id in permitted_user_ids or any(
|
||||
group_id in permitted_group_ids for group_id in user_group_ids
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# Get all users with access to a resource
|
||||
def get_users_with_access(
|
||||
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"]}, db=db)
|
||||
return result.get("users", [])
|
||||
def migrate_access_control(
|
||||
data: dict, ac_key: str = "access_control", grants_key: str = "access_grants"
|
||||
) -> None:
|
||||
"""
|
||||
Auto-migrate a config dict in-place from legacy access_control dict to access_grants list.
|
||||
|
||||
permitted_ids = get_permitted_group_and_user_ids(type, access_control)
|
||||
if permitted_ids is None:
|
||||
return []
|
||||
If `grants_key` already exists, does nothing.
|
||||
If `ac_key` exists (old format), converts it and stores as `grants_key`, then removes `ac_key`.
|
||||
"""
|
||||
if grants_key in data:
|
||||
return
|
||||
|
||||
permitted_group_ids = permitted_ids.get("group_ids", [])
|
||||
permitted_user_ids = permitted_ids.get("user_ids", [])
|
||||
access_control = data.get(ac_key)
|
||||
if access_control is None and ac_key not in data:
|
||||
return
|
||||
|
||||
user_ids_with_access = set(permitted_user_ids)
|
||||
grants: List[Dict[str, str]] = []
|
||||
if access_control and isinstance(access_control, dict):
|
||||
for perm in ["read", "write"]:
|
||||
perm_data = access_control.get(perm, {})
|
||||
if not perm_data:
|
||||
continue
|
||||
for group_id in perm_data.get("group_ids", []):
|
||||
grants.append(
|
||||
{
|
||||
"principal_type": "group",
|
||||
"principal_id": group_id,
|
||||
"permission": perm,
|
||||
}
|
||||
)
|
||||
for uid in perm_data.get("user_ids", []):
|
||||
grants.append(
|
||||
{
|
||||
"principal_type": "user",
|
||||
"principal_id": uid,
|
||||
"permission": perm,
|
||||
}
|
||||
)
|
||||
|
||||
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), db=db)
|
||||
data[grants_key] = grants
|
||||
data.pop(ac_key, None)
|
||||
|
|
|
|||
139
backend/open_webui/utils/actions.py
Normal file
139
backend/open_webui/utils/actions.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import logging
|
||||
import sys
|
||||
import inspect
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.functions import Functions
|
||||
|
||||
from open_webui.socket.main import get_event_call, get_event_emitter
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
from open_webui.utils.models import get_all_models
|
||||
from open_webui.utils.middleware import process_tool_result
|
||||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
|
||||
if "." in action_id:
|
||||
action_id, sub_action_id = action_id.split(".")
|
||||
else:
|
||||
sub_action_id = None
|
||||
|
||||
action = Functions.get_function_by_id(action_id)
|
||||
if not action:
|
||||
raise Exception(f"Action not found: {action_id}")
|
||||
|
||||
if not request.app.state.MODELS:
|
||||
await get_all_models(request, user=user)
|
||||
|
||||
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
|
||||
models = {
|
||||
request.state.model["id"]: request.state.model,
|
||||
}
|
||||
else:
|
||||
models = request.app.state.MODELS
|
||||
|
||||
data = form_data
|
||||
model_id = data["model"]
|
||||
|
||||
if model_id not in models:
|
||||
raise Exception("Model not found")
|
||||
model = models[model_id]
|
||||
|
||||
__event_emitter__ = get_event_emitter(
|
||||
{
|
||||
"chat_id": data["chat_id"],
|
||||
"message_id": data["id"],
|
||||
"session_id": data["session_id"],
|
||||
"user_id": user.id,
|
||||
}
|
||||
)
|
||||
__event_call__ = get_event_call(
|
||||
{
|
||||
"chat_id": data["chat_id"],
|
||||
"message_id": data["id"],
|
||||
"session_id": data["session_id"],
|
||||
"user_id": user.id,
|
||||
}
|
||||
)
|
||||
|
||||
function_module, _, _ = get_function_module_from_cache(request, action_id)
|
||||
|
||||
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
||||
valves = Functions.get_function_valves_by_id(action_id)
|
||||
function_module.valves = function_module.Valves(**(valves if valves else {}))
|
||||
|
||||
if hasattr(function_module, "action"):
|
||||
try:
|
||||
action = function_module.action
|
||||
|
||||
# Get the signature of the function
|
||||
sig = inspect.signature(action)
|
||||
params = {"body": data}
|
||||
|
||||
# Extra parameters to be passed to the function
|
||||
extra_params = {
|
||||
"__model__": model,
|
||||
"__id__": sub_action_id if sub_action_id is not None else action_id,
|
||||
"__event_emitter__": __event_emitter__,
|
||||
"__event_call__": __event_call__,
|
||||
"__request__": request,
|
||||
}
|
||||
|
||||
# Add extra params in contained in function signature
|
||||
for key, value in extra_params.items():
|
||||
if key in sig.parameters:
|
||||
params[key] = value
|
||||
|
||||
if "__user__" in sig.parameters:
|
||||
__user__ = user.model_dump() if isinstance(user, UserModel) else {}
|
||||
|
||||
try:
|
||||
if hasattr(function_module, "UserValves"):
|
||||
__user__["valves"] = function_module.UserValves(
|
||||
**Functions.get_user_valves_by_id_and_user_id(
|
||||
action_id, user.id
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to get user values: {e}")
|
||||
|
||||
params = {**params, "__user__": __user__}
|
||||
|
||||
if inspect.iscoroutinefunction(action):
|
||||
data = await action(**params)
|
||||
else:
|
||||
data = action(**params)
|
||||
|
||||
# Process action result for Rich UI embeds (HTMLResponse, tuple with headers)
|
||||
processed_result, _, action_embeds = process_tool_result(
|
||||
request,
|
||||
action_id,
|
||||
data,
|
||||
"action",
|
||||
)
|
||||
|
||||
if action_embeds:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "embeds",
|
||||
"data": {
|
||||
"embeds": action_embeds,
|
||||
},
|
||||
}
|
||||
)
|
||||
# Replace data with the processed status dict so we don't
|
||||
# try to serialize the raw HTMLResponse / tuple back to the client
|
||||
data = processed_result
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error: {e}")
|
||||
|
||||
return data
|
||||
|
|
@ -28,7 +28,6 @@ from open_webui.env import AUDIT_LOG_LEVEL, MAX_BODY_LOG_SIZE
|
|||
from open_webui.utils.auth import get_current_user, get_http_authorization_cred
|
||||
from open_webui.models.users import UserModel
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from loguru import Logger
|
||||
|
||||
|
|
@ -222,7 +221,9 @@ class AuditLoggingMiddleware:
|
|||
|
||||
# Skip logging if the request is not authenticated
|
||||
# Check both Authorization header (API keys) and token cookie (browser sessions)
|
||||
if not request.headers.get("authorization") and not request.cookies.get("token"):
|
||||
if not request.headers.get("authorization") and not request.cookies.get(
|
||||
"token"
|
||||
):
|
||||
return True
|
||||
|
||||
# match either /api/<resource>/...(for the endpoint /api/chat case) or /api/v1/<resource>/...
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ from open_webui.env import (
|
|||
from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SESSION_SECRET = WEBUI_SECRET_KEY
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from aiocache import cached
|
|||
from typing import Any, Optional
|
||||
import random
|
||||
import json
|
||||
import inspect
|
||||
|
||||
import uuid
|
||||
import asyncio
|
||||
|
||||
|
|
@ -57,7 +57,6 @@ from open_webui.utils.filter import (
|
|||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
|
||||
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -341,7 +340,9 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
|||
}
|
||||
|
||||
try:
|
||||
filter_ids = get_sorted_filter_ids(request, model, metadata.get("filter_ids", []))
|
||||
filter_ids = get_sorted_filter_ids(
|
||||
request, model, metadata.get("filter_ids", [])
|
||||
)
|
||||
filter_functions = Functions.get_functions_by_ids(filter_ids)
|
||||
|
||||
result, _ = await process_filter_functions(
|
||||
|
|
@ -354,101 +355,3 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
|||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Error: {e}")
|
||||
|
||||
|
||||
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
|
||||
if "." in action_id:
|
||||
action_id, sub_action_id = action_id.split(".")
|
||||
else:
|
||||
sub_action_id = None
|
||||
|
||||
action = Functions.get_function_by_id(action_id)
|
||||
if not action:
|
||||
raise Exception(f"Action not found: {action_id}")
|
||||
|
||||
if not request.app.state.MODELS:
|
||||
await get_all_models(request, user=user)
|
||||
|
||||
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
|
||||
models = {
|
||||
request.state.model["id"]: request.state.model,
|
||||
}
|
||||
else:
|
||||
models = request.app.state.MODELS
|
||||
|
||||
data = form_data
|
||||
model_id = data["model"]
|
||||
|
||||
if model_id not in models:
|
||||
raise Exception("Model not found")
|
||||
model = models[model_id]
|
||||
|
||||
__event_emitter__ = get_event_emitter(
|
||||
{
|
||||
"chat_id": data["chat_id"],
|
||||
"message_id": data["id"],
|
||||
"session_id": data["session_id"],
|
||||
"user_id": user.id,
|
||||
}
|
||||
)
|
||||
__event_call__ = get_event_call(
|
||||
{
|
||||
"chat_id": data["chat_id"],
|
||||
"message_id": data["id"],
|
||||
"session_id": data["session_id"],
|
||||
"user_id": user.id,
|
||||
}
|
||||
)
|
||||
|
||||
function_module, _, _ = get_function_module_from_cache(request, action_id)
|
||||
|
||||
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
||||
valves = Functions.get_function_valves_by_id(action_id)
|
||||
function_module.valves = function_module.Valves(**(valves if valves else {}))
|
||||
|
||||
if hasattr(function_module, "action"):
|
||||
try:
|
||||
action = function_module.action
|
||||
|
||||
# Get the signature of the function
|
||||
sig = inspect.signature(action)
|
||||
params = {"body": data}
|
||||
|
||||
# Extra parameters to be passed to the function
|
||||
extra_params = {
|
||||
"__model__": model,
|
||||
"__id__": sub_action_id if sub_action_id is not None else action_id,
|
||||
"__event_emitter__": __event_emitter__,
|
||||
"__event_call__": __event_call__,
|
||||
"__request__": request,
|
||||
}
|
||||
|
||||
# Add extra params in contained in function signature
|
||||
for key, value in extra_params.items():
|
||||
if key in sig.parameters:
|
||||
params[key] = value
|
||||
|
||||
if "__user__" in sig.parameters:
|
||||
__user__ = user.model_dump() if isinstance(user, UserModel) else {}
|
||||
|
||||
try:
|
||||
if hasattr(function_module, "UserValves"):
|
||||
__user__["valves"] = function_module.UserValves(
|
||||
**Functions.get_user_valves_by_id_and_user_id(
|
||||
action_id, user.id
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to get user values: {e}")
|
||||
|
||||
params = {**params, "__user__": __user__}
|
||||
|
||||
if inspect.iscoroutinefunction(action):
|
||||
data = await action(**params)
|
||||
else:
|
||||
data = action(**params)
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error: {e}")
|
||||
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import aiohttp
|
|||
import websockets
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
|
||||
|
||||
|
||||
def has_permission(db, DocumentModel, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
conditions = []
|
||||
|
||||
# Handle read_only permission separately
|
||||
if permission == "read_only":
|
||||
# For read_only, we want items where:
|
||||
# 1. User has explicit read permission (via groups or user-level)
|
||||
# 2. BUT does NOT have write permission
|
||||
# 3. Public items are NOT considered read_only
|
||||
|
||||
read_conditions = []
|
||||
|
||||
# Group-level read permission
|
||||
if group_ids:
|
||||
group_read_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_read_conditions.append(
|
||||
DocumentModel.access_control["read"]["group_ids"].contains(gid)
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_read_conditions.append(
|
||||
cast(
|
||||
DocumentModel.access_control["read"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_read_conditions:
|
||||
read_conditions.append(or_(*group_read_conditions))
|
||||
|
||||
# Combine read conditions
|
||||
if read_conditions:
|
||||
has_read = or_(*read_conditions)
|
||||
else:
|
||||
# If no read conditions, return empty result
|
||||
return query.filter(False)
|
||||
|
||||
# Now exclude items where user has write permission
|
||||
write_exclusions = []
|
||||
|
||||
# Exclude items owned by user (they have implicit write)
|
||||
if user_id:
|
||||
write_exclusions.append(DocumentModel.user_id != user_id)
|
||||
|
||||
# Exclude items where user has explicit write permission via groups
|
||||
if group_ids:
|
||||
group_write_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_write_conditions.append(
|
||||
DocumentModel.access_control["write"]["group_ids"].contains(gid)
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_write_conditions.append(
|
||||
cast(
|
||||
DocumentModel.access_control["write"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_write_conditions:
|
||||
# User should NOT have write permission
|
||||
write_exclusions.append(~or_(*group_write_conditions))
|
||||
|
||||
# Exclude public items (items without access_control)
|
||||
write_exclusions.append(DocumentModel.access_control.isnot(None))
|
||||
write_exclusions.append(cast(DocumentModel.access_control, String) != "null")
|
||||
|
||||
# Combine: has read AND does not have write AND not public
|
||||
if write_exclusions:
|
||||
query = query.filter(and_(has_read, *write_exclusions))
|
||||
else:
|
||||
query = query.filter(has_read)
|
||||
|
||||
return query
|
||||
|
||||
# Original logic for other permissions (read, write, etc.)
|
||||
# Public access conditions
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
DocumentModel.access_control.is_(None),
|
||||
cast(DocumentModel.access_control, String) == "null",
|
||||
]
|
||||
)
|
||||
|
||||
# User-level permission (owner has all permissions)
|
||||
if user_id:
|
||||
conditions.append(DocumentModel.user_id == user_id)
|
||||
|
||||
# Group-level permission
|
||||
if group_ids:
|
||||
group_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_conditions.append(
|
||||
DocumentModel.access_control[permission]["group_ids"].contains(gid)
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_conditions.append(
|
||||
cast(
|
||||
DocumentModel.access_control[permission]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
conditions.append(or_(*group_conditions))
|
||||
|
||||
if conditions:
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query
|
||||
|
|
@ -10,12 +10,11 @@ from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
|
|||
|
||||
from open_webui.routers.openai import embeddings as openai_embeddings
|
||||
from open_webui.routers.ollama import (
|
||||
embeddings as ollama_embeddings,
|
||||
GenerateEmbeddingsForm,
|
||||
embed as ollama_embed,
|
||||
GenerateEmbedForm,
|
||||
)
|
||||
|
||||
|
||||
from open_webui.utils.payload import convert_embedding_payload_openai_to_ollama
|
||||
from open_webui.utils.payload import convert_embed_payload_openai_to_ollama
|
||||
from open_webui.utils.response import convert_embedding_response_ollama_to_openai
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
|
|
@ -71,12 +70,12 @@ async def generate_embeddings(
|
|||
if not bypass_filter and user.role == "user":
|
||||
check_model_access(user, model)
|
||||
|
||||
# Ollama backend
|
||||
# Ollama backend — use /api/embed which supports batch input natively
|
||||
if model.get("owned_by") == "ollama":
|
||||
ollama_payload = convert_embedding_payload_openai_to_ollama(form_data)
|
||||
response = await ollama_embeddings(
|
||||
ollama_payload = convert_embed_payload_openai_to_ollama(form_data)
|
||||
response = await ollama_embed(
|
||||
request=request,
|
||||
form_data=GenerateEmbeddingsForm(**ollama_payload),
|
||||
form_data=GenerateEmbedForm(**ollama_payload),
|
||||
user=user,
|
||||
)
|
||||
return convert_embedding_response_ollama_to_openai(response)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from open_webui.env import (
|
|||
ENABLE_OTEL_LOGS,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from loguru import Record
|
||||
|
||||
|
|
|
|||
|
|
@ -129,11 +129,11 @@ from open_webui.env import (
|
|||
RAG_SYSTEM_CONTEXT,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
|
||||
)
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.constants import TASKS
|
||||
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -171,9 +171,13 @@ def get_citation_source_from_tool_result(
|
|||
Returns a list of sources (usually one, but query_knowledge_files may return multiple).
|
||||
"""
|
||||
try:
|
||||
tool_result = json.loads(tool_result)
|
||||
if isinstance(tool_result, dict) and "error" in tool_result:
|
||||
return []
|
||||
|
||||
if tool_name == "search_web":
|
||||
# Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}]
|
||||
results = json.loads(tool_result)
|
||||
results = tool_result
|
||||
documents = []
|
||||
metadata = []
|
||||
|
||||
|
|
@ -200,7 +204,7 @@ def get_citation_source_from_tool_result(
|
|||
]
|
||||
|
||||
elif tool_name == "view_knowledge_file":
|
||||
file_data = json.loads(tool_result)
|
||||
file_data = tool_result
|
||||
filename = file_data.get("filename", "Unknown File")
|
||||
file_id = file_data.get("id", "")
|
||||
knowledge_name = file_data.get("knowledge_name", "")
|
||||
|
|
@ -229,7 +233,7 @@ def get_citation_source_from_tool_result(
|
|||
]
|
||||
|
||||
elif tool_name == "query_knowledge_files":
|
||||
chunks = json.loads(tool_result)
|
||||
chunks = tool_result
|
||||
|
||||
# Group chunks by source for better citation display
|
||||
# Each unique source becomes a separate source entry
|
||||
|
|
@ -860,7 +864,7 @@ def process_tool_result(
|
|||
else:
|
||||
tool_result = tool_result.body.decode("utf-8", "replace")
|
||||
|
||||
elif (tool_type == "external" and isinstance(tool_result, tuple)) or (
|
||||
elif (tool_type in ("external", "action") and isinstance(tool_result, tuple)) or (
|
||||
direct_tool and isinstance(tool_result, list) and len(tool_result) == 2
|
||||
):
|
||||
tool_result, tool_response_headers = tool_result
|
||||
|
|
@ -1255,6 +1259,7 @@ async def chat_web_search_handler(
|
|||
"messages": messages,
|
||||
"prompt": user_message,
|
||||
"type": "web_search",
|
||||
"chat_id": extra_params.get("__chat_id__"),
|
||||
},
|
||||
user,
|
||||
)
|
||||
|
|
@ -1456,7 +1461,9 @@ def add_file_context(messages: list, chat_id: str, user) -> list:
|
|||
|
||||
for message, stored_message in zip(messages, stored_messages):
|
||||
files_with_urls = [
|
||||
file for file in stored_message.get("files", []) if file.get("url")
|
||||
file
|
||||
for file in stored_message.get("files", [])
|
||||
if file.get("url") and not file.get("url").startswith("data:")
|
||||
]
|
||||
if not files_with_urls:
|
||||
continue
|
||||
|
|
@ -1583,6 +1590,7 @@ async def chat_image_generation_handler(
|
|||
{
|
||||
"model": form_data["model"],
|
||||
"messages": form_data["messages"],
|
||||
"chat_id": metadata.get("chat_id"),
|
||||
},
|
||||
user,
|
||||
)
|
||||
|
|
@ -1689,6 +1697,7 @@ async def chat_completion_files_handler(
|
|||
"model": body["model"],
|
||||
"messages": body["messages"],
|
||||
"type": "retrieval",
|
||||
"chat_id": body.get("metadata", {}).get("chat_id"),
|
||||
},
|
||||
user,
|
||||
)
|
||||
|
|
@ -2097,6 +2106,35 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
tool_ids = form_data.pop("tool_ids", None)
|
||||
files = form_data.pop("files", None)
|
||||
|
||||
# Skills: inject manifest only — model uses view_skill tool to load full content on-demand
|
||||
user_skill_ids = form_data.pop("skill_ids", None) or []
|
||||
model_skill_ids = model.get("info", {}).get("meta", {}).get("skillIds", [])
|
||||
|
||||
all_skill_ids = list(set(user_skill_ids + model_skill_ids))
|
||||
available_skills = []
|
||||
if all_skill_ids:
|
||||
from open_webui.models.skills import Skills as SkillsModel
|
||||
|
||||
accessible_skill_ids = {
|
||||
s.id for s in SkillsModel.get_skills_by_user_id(user.id, "read")
|
||||
}
|
||||
available_skills = [
|
||||
s
|
||||
for sid in all_skill_ids
|
||||
if sid in accessible_skill_ids
|
||||
and (s := SkillsModel.get_skill_by_id(sid))
|
||||
and s.is_active
|
||||
]
|
||||
|
||||
if available_skills:
|
||||
manifest = "<available_skills>\n"
|
||||
for skill in available_skills:
|
||||
manifest += f"<skill>\n<name>{skill.name}</name>\n<description>{skill.description or ''}</description>\n</skill>\n"
|
||||
manifest += "</available_skills>"
|
||||
form_data["messages"] = add_or_update_system_message(
|
||||
manifest, form_data["messages"], append=True
|
||||
)
|
||||
|
||||
prompt = get_last_user_message(form_data["messages"])
|
||||
# TODO: re-enable URL extraction from prompt
|
||||
# urls = []
|
||||
|
|
@ -2214,7 +2252,13 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
if metadata and metadata.get("chat_id"):
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get(
|
||||
"chat_id"
|
||||
)
|
||||
if metadata and metadata.get("message_id"):
|
||||
headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = (
|
||||
metadata.get("message_id")
|
||||
)
|
||||
|
||||
mcp_clients[server_id] = MCPClient()
|
||||
await mcp_clients[server_id].connect(
|
||||
|
|
@ -2325,6 +2369,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
{
|
||||
**extra_params,
|
||||
"__event_emitter__": event_emitter,
|
||||
"__skill_ids__": [s.id for s in available_skills],
|
||||
},
|
||||
features,
|
||||
model,
|
||||
|
|
@ -2787,9 +2832,7 @@ async def non_streaming_chat_response_handler(response, ctx):
|
|||
"id": output_id("msg"),
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": content}
|
||||
],
|
||||
"content": [{"type": "output_text", "text": content}],
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -2942,9 +2985,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
match = re.search(start_tag_pattern, content)
|
||||
if match:
|
||||
try:
|
||||
attr_content = (
|
||||
match.group(1) if match.group(1) else ""
|
||||
)
|
||||
attr_content = match.group(1) if match.group(1) else ""
|
||||
except:
|
||||
attr_content = ""
|
||||
|
||||
|
|
@ -2957,7 +2998,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
current_text = get_last_text(output)
|
||||
set_last_text(
|
||||
output,
|
||||
current_text.replace(match.group(0) + after_tag, "")
|
||||
current_text.replace(match.group(0) + after_tag, ""),
|
||||
)
|
||||
|
||||
if before_tag:
|
||||
|
|
@ -3006,7 +3047,9 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
"id": output_id("msg"),
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": ""}],
|
||||
"content": [
|
||||
{"type": "output_text", "text": ""}
|
||||
],
|
||||
"_tag_type": content_type,
|
||||
"start_tag": start_tag,
|
||||
"end_tag": end_tag,
|
||||
|
|
@ -3034,8 +3077,14 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
|
||||
elif (
|
||||
(last_type == "reasoning" and content_type == "reasoning")
|
||||
or (last_type == "open_webui:code_interpreter" and content_type == "code_interpreter")
|
||||
or (last_type == "message" and output[-1].get("_tag_type") == content_type)
|
||||
or (
|
||||
last_type == "open_webui:code_interpreter"
|
||||
and content_type == "code_interpreter"
|
||||
)
|
||||
or (
|
||||
last_type == "message"
|
||||
and output[-1].get("_tag_type") == content_type
|
||||
)
|
||||
):
|
||||
item = output[-1]
|
||||
start_tag = item.get("start_tag", "")
|
||||
|
|
@ -3153,9 +3202,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
# Clean processed content
|
||||
start_tag_clean = rf"{re.escape(start_tag)}"
|
||||
if start_tag.startswith("<") and start_tag.endswith(">"):
|
||||
start_tag_clean = (
|
||||
rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>"
|
||||
)
|
||||
start_tag_clean = rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>"
|
||||
|
||||
content = re.sub(
|
||||
rf"{start_tag_clean}(.|\n)*?{re.escape(end_tag)}",
|
||||
|
|
@ -3206,7 +3253,6 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
else:
|
||||
output = []
|
||||
|
||||
|
||||
usage = None
|
||||
|
||||
reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags")
|
||||
|
|
@ -3489,14 +3535,19 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
for tc in response_tool_calls:
|
||||
call_id = tc.get("id", "")
|
||||
func = tc.get("function", {})
|
||||
pending_fc_items.append({
|
||||
"type": "function_call",
|
||||
"id": call_id or output_id("fc"),
|
||||
"call_id": call_id,
|
||||
"name": func.get("name", ""),
|
||||
"arguments": func.get("arguments", "{}"),
|
||||
"status": "in_progress",
|
||||
})
|
||||
pending_fc_items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": call_id
|
||||
or output_id("fc"),
|
||||
"call_id": call_id,
|
||||
"name": func.get("name", ""),
|
||||
"arguments": func.get(
|
||||
"arguments", "{}"
|
||||
),
|
||||
"status": "in_progress",
|
||||
}
|
||||
)
|
||||
pending_output = output + pending_fc_items
|
||||
await event_emitter(
|
||||
{
|
||||
|
|
@ -3560,22 +3611,25 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
|
||||
# Append to reasoning content
|
||||
parts = reasoning_item.get("content", [])
|
||||
if parts and parts[-1].get("type") == "output_text":
|
||||
if (
|
||||
parts
|
||||
and parts[-1].get("type") == "output_text"
|
||||
):
|
||||
parts[-1]["text"] += reasoning_content
|
||||
else:
|
||||
reasoning_item["content"] = [
|
||||
{"type": "output_text", "text": reasoning_content}
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": reasoning_content,
|
||||
}
|
||||
]
|
||||
|
||||
data = {
|
||||
"content": serialize_output(output)
|
||||
}
|
||||
data = {"content": serialize_output(output)}
|
||||
|
||||
if value:
|
||||
if (
|
||||
output
|
||||
and output[-1].get("type")
|
||||
== "reasoning"
|
||||
and output[-1].get("type") == "reasoning"
|
||||
and output[-1]
|
||||
.get("attributes", {})
|
||||
.get("type")
|
||||
|
|
@ -3595,7 +3649,12 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
"id": output_id("msg"),
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": ""}],
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -3625,13 +3684,22 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
"id": output_id("msg"),
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": ""}],
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Append value to last message item's text
|
||||
msg_parts = output[-1].get("content", [])
|
||||
if msg_parts and msg_parts[-1].get("type") == "output_text":
|
||||
if (
|
||||
msg_parts
|
||||
and msg_parts[-1].get("type")
|
||||
== "output_text"
|
||||
):
|
||||
msg_parts[-1]["text"] += value
|
||||
else:
|
||||
output[-1]["content"] = [
|
||||
|
|
@ -3639,32 +3707,26 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
]
|
||||
|
||||
if DETECT_REASONING_TAGS:
|
||||
content, output, _ = (
|
||||
tag_output_handler(
|
||||
"reasoning",
|
||||
reasoning_tags,
|
||||
content,
|
||||
output,
|
||||
)
|
||||
content, output, _ = tag_output_handler(
|
||||
"reasoning",
|
||||
reasoning_tags,
|
||||
content,
|
||||
output,
|
||||
)
|
||||
|
||||
content, output, _ = (
|
||||
tag_output_handler(
|
||||
"solution",
|
||||
DEFAULT_SOLUTION_TAGS,
|
||||
content,
|
||||
output,
|
||||
)
|
||||
content, output, _ = tag_output_handler(
|
||||
"solution",
|
||||
DEFAULT_SOLUTION_TAGS,
|
||||
content,
|
||||
output,
|
||||
)
|
||||
|
||||
if DETECT_CODE_INTERPRETER:
|
||||
content, output, end = (
|
||||
tag_output_handler(
|
||||
"code_interpreter",
|
||||
DEFAULT_CODE_INTERPRETER_TAGS,
|
||||
content,
|
||||
output,
|
||||
)
|
||||
content, output, end = tag_output_handler(
|
||||
"code_interpreter",
|
||||
DEFAULT_CODE_INTERPRETER_TAGS,
|
||||
content,
|
||||
output,
|
||||
)
|
||||
|
||||
if end:
|
||||
|
|
@ -3682,9 +3744,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
else:
|
||||
data = {
|
||||
"content": serialize_output(
|
||||
output
|
||||
),
|
||||
"content": serialize_output(output),
|
||||
}
|
||||
|
||||
if delta:
|
||||
|
|
@ -3725,7 +3785,9 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
"id": output_id("msg"),
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": ""}],
|
||||
"content": [
|
||||
{"type": "output_text", "text": ""}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -3763,14 +3825,16 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
for tc in response_tool_calls:
|
||||
call_id = tc.get("id", "")
|
||||
func = tc.get("function", {})
|
||||
output.append({
|
||||
"type": "function_call",
|
||||
"id": call_id or output_id("fc"),
|
||||
"call_id": call_id,
|
||||
"name": func.get("name", ""),
|
||||
"arguments": func.get("arguments", "{}"),
|
||||
"status": "in_progress",
|
||||
})
|
||||
output.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": call_id or output_id("fc"),
|
||||
"call_id": call_id,
|
||||
"name": func.get("name", ""),
|
||||
"arguments": func.get("arguments", "{}"),
|
||||
"status": "in_progress",
|
||||
}
|
||||
)
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
|
|
@ -3929,35 +3993,42 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
call_id = tc.get("id", "")
|
||||
# Mark function_call as completed
|
||||
for item in output:
|
||||
if item.get("type") == "function_call" and item.get("call_id") == call_id:
|
||||
if (
|
||||
item.get("type") == "function_call"
|
||||
and item.get("call_id") == call_id
|
||||
):
|
||||
item["status"] = "completed"
|
||||
# Update arguments with parsed/sanitized version
|
||||
item["arguments"] = tc.get("function", {}).get("arguments", "{}")
|
||||
item["arguments"] = tc.get("function", {}).get(
|
||||
"arguments", "{}"
|
||||
)
|
||||
break
|
||||
|
||||
for result in results:
|
||||
output.append({
|
||||
"type": "function_call_output",
|
||||
"id": output_id("fco"),
|
||||
"call_id": result.get("tool_call_id", ""),
|
||||
"output": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": result.get("content", ""),
|
||||
}
|
||||
],
|
||||
"status": "completed",
|
||||
**(
|
||||
{"files": result.get("files")}
|
||||
if result.get("files")
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"embeds": result.get("embeds")}
|
||||
if result.get("embeds")
|
||||
else {}
|
||||
),
|
||||
})
|
||||
output.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"id": output_id("fco"),
|
||||
"call_id": result.get("tool_call_id", ""),
|
||||
"output": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": result.get("content", ""),
|
||||
}
|
||||
],
|
||||
"status": "completed",
|
||||
**(
|
||||
{"files": result.get("files")}
|
||||
if result.get("files")
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"embeds": result.get("embeds")}
|
||||
if result.get("embeds")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Append a new empty message item for the next response
|
||||
output.append(
|
||||
|
|
@ -4054,8 +4125,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
code = sanitize_code(code)
|
||||
|
||||
if CODE_INTERPRETER_BLOCKED_MODULES:
|
||||
blocking_code = textwrap.dedent(
|
||||
f"""
|
||||
blocking_code = textwrap.dedent(f"""
|
||||
import builtins
|
||||
|
||||
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
|
||||
|
|
@ -4071,8 +4141,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
return _real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
builtins.__import__ = restricted_import
|
||||
"""
|
||||
)
|
||||
""")
|
||||
code = blocking_code + "\n" + code
|
||||
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -151,11 +151,15 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
def flush_pending():
|
||||
nonlocal pending_content, pending_tool_calls
|
||||
if pending_content or pending_tool_calls:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "\n".join(pending_content) if pending_content else "",
|
||||
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "\n".join(pending_content) if pending_content else "",
|
||||
**(
|
||||
{"tool_calls": pending_tool_calls} if pending_tool_calls else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
pending_content = []
|
||||
pending_tool_calls = []
|
||||
|
||||
|
|
@ -178,14 +182,16 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
# Ensure arguments is always a JSON string
|
||||
if not isinstance(arguments, str):
|
||||
arguments = json.dumps(arguments)
|
||||
pending_tool_calls.append({
|
||||
"id": item.get("call_id", ""),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": item.get("name", ""),
|
||||
"arguments": arguments,
|
||||
pending_tool_calls.append(
|
||||
{
|
||||
"id": item.get("call_id", ""),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": item.get("name", ""),
|
||||
"arguments": arguments,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
elif item_type == "function_call_output":
|
||||
# Flush any pending content/tool_calls before adding tool result
|
||||
|
|
@ -198,11 +204,13 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
if part.get("type") == "input_text":
|
||||
content += part.get("text", "")
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": item.get("call_id", ""),
|
||||
"content": content,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": item.get("call_id", ""),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
elif item_type == "reasoning":
|
||||
if raw:
|
||||
|
|
@ -218,9 +226,7 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
if reasoning_text:
|
||||
start_tag = item.get("start_tag", "<think>")
|
||||
end_tag = item.get("end_tag", "</think>")
|
||||
pending_content.append(
|
||||
f"{start_tag}{reasoning_text}{end_tag}"
|
||||
)
|
||||
pending_content.append(f"{start_tag}{reasoning_text}{end_tag}")
|
||||
# else: skip reasoning blocks for normal LLM messages
|
||||
|
||||
elif item_type == "open_webui:code_interpreter":
|
||||
|
|
@ -776,6 +782,31 @@ def extract_urls(text: str) -> list[str]:
|
|||
return url_pattern.findall(text)
|
||||
|
||||
|
||||
async def cleanup_response(
|
||||
response: Optional[aiohttp.ClientResponse],
|
||||
session: Optional[aiohttp.ClientSession],
|
||||
):
|
||||
if response:
|
||||
response.close()
|
||||
if session:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def stream_wrapper(response, session, content_handler=None):
|
||||
"""
|
||||
Wrap a stream to ensure cleanup happens even if streaming is interrupted.
|
||||
This is more reliable than BackgroundTask which may not run if client disconnects.
|
||||
"""
|
||||
try:
|
||||
stream = (
|
||||
content_handler(response.content) if content_handler else response.content
|
||||
)
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
finally:
|
||||
await cleanup_response(response, session)
|
||||
|
||||
|
||||
def stream_chunks_handler(stream: aiohttp.StreamReader):
|
||||
"""
|
||||
Handle stream response chunks, supporting large data chunks that exceed the original 16kb limit.
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ from open_webui.config import (
|
|||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.users import UserModel
|
||||
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -340,12 +339,12 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
|
||||
def check_model_access(user, model, db=None):
|
||||
if model.get("arena"):
|
||||
meta = model.get("info", {}).get("meta", {})
|
||||
access_grants = meta.get("access_grants", [])
|
||||
if not has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("access_control", {}),
|
||||
permission="read",
|
||||
access_grants=access_grants,
|
||||
db=db,
|
||||
):
|
||||
raise Exception("Model not found")
|
||||
|
|
@ -384,12 +383,12 @@ def get_filtered_models(models, user, db=None):
|
|||
}
|
||||
for model in models:
|
||||
if model.get("arena"):
|
||||
meta = model.get("info", {}).get("meta", {})
|
||||
access_grants = meta.get("access_grants", [])
|
||||
if has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("access_control", {}),
|
||||
permission="read",
|
||||
access_grants=access_grants,
|
||||
user_group_ids=user_group_ids,
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue