diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1ec947b4e0..9a19bfa6b4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ - 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. \ No newline at end of file +> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in. diff --git a/CHANGELOG.md b/CHANGELOG.md index 572ac40257..955c1f066e 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 02b8192522..4d8076f5f1 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -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") diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 4289475c04..4ec2e2d8e2 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -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 #################################### diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 5381daf0cc..364d2a889b 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -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__) diff --git a/backend/open_webui/internal/migrations/001_initial_schema.py b/backend/open_webui/internal/migrations/001_initial_schema.py index 93f278f15b..0df2249b21 100644 --- a/backend/open_webui/internal/migrations/001_initial_schema.py +++ b/backend/open_webui/internal/migrations/001_initial_schema.py @@ -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 diff --git a/backend/open_webui/internal/migrations/002_add_local_sharing.py b/backend/open_webui/internal/migrations/002_add_local_sharing.py index e93501aeec..a01862d103 100644 --- a/backend/open_webui/internal/migrations/002_add_local_sharing.py +++ b/backend/open_webui/internal/migrations/002_add_local_sharing.py @@ -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 diff --git a/backend/open_webui/internal/migrations/003_add_auth_api_key.py b/backend/open_webui/internal/migrations/003_add_auth_api_key.py index 07144f3aca..23cba26383 100644 --- a/backend/open_webui/internal/migrations/003_add_auth_api_key.py +++ b/backend/open_webui/internal/migrations/003_add_auth_api_key.py @@ -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 diff --git a/backend/open_webui/internal/migrations/004_add_archived.py b/backend/open_webui/internal/migrations/004_add_archived.py index d01c06b4e6..11108a3e0b 100644 --- a/backend/open_webui/internal/migrations/004_add_archived.py +++ b/backend/open_webui/internal/migrations/004_add_archived.py @@ -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 diff --git a/backend/open_webui/internal/migrations/005_add_updated_at.py b/backend/open_webui/internal/migrations/005_add_updated_at.py index 950866ef02..f7fc69a5db 100644 --- a/backend/open_webui/internal/migrations/005_add_updated_at.py +++ b/backend/open_webui/internal/migrations/005_add_updated_at.py @@ -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 diff --git a/backend/open_webui/internal/migrations/006_migrate_timestamps_and_charfields.py b/backend/open_webui/internal/migrations/006_migrate_timestamps_and_charfields.py index caca14d323..abe7016c57 100644 --- a/backend/open_webui/internal/migrations/006_migrate_timestamps_and_charfields.py +++ b/backend/open_webui/internal/migrations/006_migrate_timestamps_and_charfields.py @@ -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 diff --git a/backend/open_webui/internal/migrations/007_add_user_last_active_at.py b/backend/open_webui/internal/migrations/007_add_user_last_active_at.py index dd176ba73e..3f89a5f59f 100644 --- a/backend/open_webui/internal/migrations/007_add_user_last_active_at.py +++ b/backend/open_webui/internal/migrations/007_add_user_last_active_at.py @@ -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 diff --git a/backend/open_webui/internal/migrations/008_add_memory.py b/backend/open_webui/internal/migrations/008_add_memory.py index 9307aa4d5c..96be907eba 100644 --- a/backend/open_webui/internal/migrations/008_add_memory.py +++ b/backend/open_webui/internal/migrations/008_add_memory.py @@ -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 diff --git a/backend/open_webui/internal/migrations/009_add_models.py b/backend/open_webui/internal/migrations/009_add_models.py index 548ec7cdca..0a8d73bd3b 100644 --- a/backend/open_webui/internal/migrations/009_add_models.py +++ b/backend/open_webui/internal/migrations/009_add_models.py @@ -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 diff --git a/backend/open_webui/internal/migrations/011_add_user_settings.py b/backend/open_webui/internal/migrations/011_add_user_settings.py index a1620dcada..c3b9ab6edc 100644 --- a/backend/open_webui/internal/migrations/011_add_user_settings.py +++ b/backend/open_webui/internal/migrations/011_add_user_settings.py @@ -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 diff --git a/backend/open_webui/internal/migrations/012_add_tools.py b/backend/open_webui/internal/migrations/012_add_tools.py index 4a68eea552..ac3cd8bfec 100644 --- a/backend/open_webui/internal/migrations/012_add_tools.py +++ b/backend/open_webui/internal/migrations/012_add_tools.py @@ -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 diff --git a/backend/open_webui/internal/migrations/013_add_user_info.py b/backend/open_webui/internal/migrations/013_add_user_info.py index 0f68669cca..6fafa951f0 100644 --- a/backend/open_webui/internal/migrations/013_add_user_info.py +++ b/backend/open_webui/internal/migrations/013_add_user_info.py @@ -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 diff --git a/backend/open_webui/internal/migrations/014_add_files.py b/backend/open_webui/internal/migrations/014_add_files.py index 5e1acf0ad8..655b00d238 100644 --- a/backend/open_webui/internal/migrations/014_add_files.py +++ b/backend/open_webui/internal/migrations/014_add_files.py @@ -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 diff --git a/backend/open_webui/internal/migrations/015_add_functions.py b/backend/open_webui/internal/migrations/015_add_functions.py index 8316a9333b..84d2843839 100644 --- a/backend/open_webui/internal/migrations/015_add_functions.py +++ b/backend/open_webui/internal/migrations/015_add_functions.py @@ -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 diff --git a/backend/open_webui/internal/migrations/016_add_valves_and_is_active.py b/backend/open_webui/internal/migrations/016_add_valves_and_is_active.py index e3af521b7e..fadf964e46 100644 --- a/backend/open_webui/internal/migrations/016_add_valves_and_is_active.py +++ b/backend/open_webui/internal/migrations/016_add_valves_and_is_active.py @@ -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 diff --git a/backend/open_webui/internal/migrations/017_add_user_oauth_sub.py b/backend/open_webui/internal/migrations/017_add_user_oauth_sub.py index eaa3fa5fe5..67a36b4889 100644 --- a/backend/open_webui/internal/migrations/017_add_user_oauth_sub.py +++ b/backend/open_webui/internal/migrations/017_add_user_oauth_sub.py @@ -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 diff --git a/backend/open_webui/internal/migrations/018_add_function_is_global.py b/backend/open_webui/internal/migrations/018_add_function_is_global.py index 04cdab7059..1e932ed710 100644 --- a/backend/open_webui/internal/migrations/018_add_function_is_global.py +++ b/backend/open_webui/internal/migrations/018_add_function_is_global.py @@ -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 diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 2ff171ebf1..cf20284f63 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -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) diff --git a/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py b/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py index 2d72583ebe..1a4ae73180 100644 --- a/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py +++ b/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py @@ -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" diff --git a/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py index c61196fcb0..57bc8748e3 100644 --- a/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py +++ b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py @@ -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 diff --git a/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py b/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py index 0c5cec1941..229bb8cffb 100644 --- a/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py +++ b/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py @@ -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" diff --git a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py index 264ce13b41..af8340a3cb 100644 --- a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py +++ b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py @@ -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" diff --git a/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py b/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py index 59fe57a421..f3ef62fd64 100644 --- a/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py +++ b/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py @@ -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" diff --git a/backend/open_webui/migrations/versions/6a39f3d8e55c_add_knowledge_table.py b/backend/open_webui/migrations/versions/6a39f3d8e55c_add_knowledge_table.py index 881e6ae641..d6083d7177 100644 --- a/backend/open_webui/migrations/versions/6a39f3d8e55c_add_knowledge_table.py +++ b/backend/open_webui/migrations/versions/6a39f3d8e55c_add_knowledge_table.py @@ -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 diff --git a/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py b/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py index 181b280666..3853ec50d9 100644 --- a/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py +++ b/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py @@ -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" diff --git a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py index 28b5340d77..c8a3647aec 100644 --- a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py +++ b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py @@ -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: diff --git a/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py index 8c52a4b22a..8b9e338309 100644 --- a/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py +++ b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py @@ -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" diff --git a/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py b/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py new file mode 100644 index 0000000000..26e9e66240 --- /dev/null +++ b/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py @@ -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") diff --git a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py index f35a382645..0472c08616 100644 --- a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py +++ b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py @@ -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, diff --git a/backend/open_webui/migrations/versions/c29facfe716b_update_file_table_path.py b/backend/open_webui/migrations/versions/c29facfe716b_update_file_table_path.py index de82854b88..7786de425f 100644 --- a/backend/open_webui/migrations/versions/c29facfe716b_update_file_table_path.py +++ b/backend/open_webui/migrations/versions/c29facfe716b_update_file_table_path.py @@ -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 diff --git a/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py b/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py index 20f4a6d7b6..fa818e1f08 100644 --- a/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py +++ b/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py @@ -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" diff --git a/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py b/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py index 1b76e67c31..5569718dd8 100644 --- a/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py +++ b/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py @@ -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 diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index aac475f3e1..fa6e79a8db 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -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, diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 2d795cd2b1..0b4639fc62 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -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" diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 3ff6fb7554..8a55da9345 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -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 --- diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index c00c6f750f..fe3539f9cd 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -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") diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 51a714cea7..6040050fc3 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -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) ] ) diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 67f2891605..09060f9bde 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -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 diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 3455208944..24a872bcc4 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -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__) diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index c41b328317..fdbfac5e7c 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -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]: diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index 0859c053aa..8fe720ecc6 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -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, diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 817cab5caf..1d21d5d910 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -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()) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index d523ae0fc1..cfece00e35 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -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: diff --git a/backend/open_webui/models/prompt_history.py b/backend/open_webui/models/prompt_history.py index 0f5e7cea87..91ca4cb445 100644 --- a/backend/open_webui/models/prompt_history.py +++ b/backend/open_webui/models/prompt_history.py @@ -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 #################### diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 544aea767b..3ab7a496ab 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -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 diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py new file mode 100644 index 0000000000..71e8f97b31 --- /dev/null +++ b/backend/open_webui/models/skills.py @@ -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() diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index da439161e9..eaac4c385d 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -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__) #################### diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 4a526d03f3..7e398c1e4a 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -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( diff --git a/backend/open_webui/retrieval/models/external.py b/backend/open_webui/retrieval/models/external.py index 095143d20d..cd24dc6af2 100644 --- a/backend/open_webui/retrieval/models/external.py +++ b/backend/open_webui/retrieval/models/external.py @@ -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__) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index e2e1b85770..96fd9d3f89 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -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: diff --git a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py index 9f16f82bc9..f4258c9eff 100644 --- a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py +++ b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py @@ -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.") diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 15430db114..481f9d92fc 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -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, diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py index fc3c98f8cf..156894bc9e 100644 --- a/backend/open_webui/retrieval/vector/dbs/pinecone.py +++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py @@ -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 diff --git a/backend/open_webui/retrieval/web/external.py b/backend/open_webui/retrieval/web/external.py index 1dd63273ae..e8cf72e9f0 100644 --- a/backend/open_webui/retrieval/web/external.py +++ b/backend/open_webui/retrieval/web/external.py @@ -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__) diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py index 82635aa8ca..e6e96992a1 100644 --- a/backend/open_webui/retrieval/web/firecrawl.py +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -3,7 +3,6 @@ from typing import Optional, List from open_webui.retrieval.web.main import SearchResult, get_filtered_results - log = logging.getLogger(__name__) diff --git a/backend/open_webui/retrieval/web/perplexity_search.py b/backend/open_webui/retrieval/web/perplexity_search.py index 5c591ff64f..744a505c05 100644 --- a/backend/open_webui/retrieval/web/perplexity_search.py +++ b/backend/open_webui/retrieval/web/perplexity_search.py @@ -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__) diff --git a/backend/open_webui/retrieval/web/yandex.py b/backend/open_webui/retrieval/web/yandex.py index fd1bc7274a..fba4ee482e 100644 --- a/backend/open_webui/retrieval/web/yandex.py +++ b/backend/open_webui/retrieval/web/yandex.py @@ -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, ) diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py index a78431f497..61aec66332 100644 --- a/backend/open_webui/routers/analytics.py +++ b/backend/open_webui/routers/analytics.py @@ -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 diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 52e0182cad..139b64f7cf 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -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() diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 586fc66ec2..7f86307e80 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -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: diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 4713e369ad..1748eaf7ea 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -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() diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 1c9b2229cf..ab0326c882 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -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__) diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index a31b958e21..3af3b1664a 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -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__) diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index a46e05473f..3711a52ab4 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -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() diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 05afa63bb6..942848bc2f 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -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"} ), } diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 7fff5e5f2a..eab00aa19b 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -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 ############################ diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index e8546b9efd..db2bf5e238 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -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 diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index b2bccc1958..7202262bbd 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -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 ############################ diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 321b06fcda..04841e87cc 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -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 ############################ diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 4f43c41d30..fea2497561 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -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) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index d8ab50221f..de978d011a 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -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. 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: diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 7a42acffc1..20fcd75eec 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -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", ) diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 77c2e84fc4..e8d4660f03 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -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, diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 01b1adac2c..6c95d3e606 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -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 = [ diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py new file mode 100644 index 0000000000..367768e61b --- /dev/null +++ b/backend/open_webui/routers/skills.py @@ -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 diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index a89e28d3af..f6404da05c 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -36,7 +36,6 @@ from open_webui.config import ( DEFAULT_VOICE_MODE_PROMPT_TEMPLATE, ) - log = logging.getLogger(__name__) router = APIRouter() diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 015bde232a..057eb509a1 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -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 ############################ diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 6eca1fcac2..87dbf1f871 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -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 diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index 22529ab1b9..49f3a5ca55 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -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() diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index e987f9c29d..0d762ee5b2 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -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") diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index ce02105bfa..425d10c812 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -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__) diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index cc91cde1a0..04dfb5a556 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -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): diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 9dd3ea1bc7..0175ba5583 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -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 . + + :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)}) diff --git a/backend/open_webui/utils/access_control.py b/backend/open_webui/utils/access_control.py index 7784f6efd7..b7ea9830db 100644 --- a/backend/open_webui/utils/access_control.py +++ b/backend/open_webui/utils/access_control.py @@ -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) diff --git a/backend/open_webui/utils/actions.py b/backend/open_webui/utils/actions.py new file mode 100644 index 0000000000..0b4b817f0a --- /dev/null +++ b/backend/open_webui/utils/actions.py @@ -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 diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 73dc140de5..c4abb445b9 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -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//...(for the endpoint /api/chat case) or /api/v1//... diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index ef09a6004d..27af3631e4 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -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 diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 1ed34b6ca7..09030b8ee2 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -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 diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index e89b970cb6..a5de56a6c1 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -8,7 +8,6 @@ import aiohttp import websockets from pydantic import BaseModel - logger = logging.getLogger(__name__) diff --git a/backend/open_webui/utils/db/access_control.py b/backend/open_webui/utils/db/access_control.py deleted file mode 100644 index 75bd337f8c..0000000000 --- a/backend/open_webui/utils/db/access_control.py +++ /dev/null @@ -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 diff --git a/backend/open_webui/utils/embeddings.py b/backend/open_webui/utils/embeddings.py index 43cbc56e5f..a2dc080cb5 100644 --- a/backend/open_webui/utils/embeddings.py +++ b/backend/open_webui/utils/embeddings.py @@ -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) diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index 4af3064235..63d5fbb3ce 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -17,7 +17,6 @@ from open_webui.env import ( ENABLE_OTEL_LOGS, ) - if TYPE_CHECKING: from loguru import Record diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 58ea3f249f..e39787f715 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -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 = "\n" + for skill in available_skills: + manifest += f"\n{skill.name}\n{skill.description or ''}\n\n" + manifest += "" + 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 ( diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index b2b10bf56e..a192f7b66d 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -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", "") end_tag = item.get("end_tag", "") - 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. diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 4224605f19..ff3a6e0caf 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -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) diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 5094c910ca..318b8f8f88 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -289,7 +289,9 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: """ # Shallow copy metadata separately (may contain non-picklable objects) metadata = openai_payload.get("metadata") - openai_payload = copy.deepcopy({k: v for k, v in openai_payload.items() if k != "metadata"}) + openai_payload = copy.deepcopy( + {k: v for k, v in openai_payload.items() if k != "metadata"} + ) if metadata is not None: openai_payload["metadata"] = dict(metadata) ollama_payload = {} @@ -395,3 +397,29 @@ def convert_embedding_payload_openai_to_ollama(openai_payload: dict) -> dict: ollama_payload[optional_key] = openai_payload[optional_key] return ollama_payload + + +def convert_embed_payload_openai_to_ollama(openai_payload: dict) -> dict: + """ + Convert an embeddings request payload from OpenAI format to Ollama's + /api/embed format, which supports batch input natively. + + Args: + openai_payload (dict): The original payload designed for OpenAI API usage. + Expected keys: "model", "input" (str or list[str]). + + Returns: + dict: A payload compatible with the Ollama /api/embed endpoint. + """ + ollama_payload = {"model": openai_payload.get("model")} + input_value = openai_payload.get("input") + + # /api/embed accepts 'input' as a string or list of strings directly + ollama_payload["input"] = input_value + + # Optionally forward other fields if present + for optional_key in ("truncate", "options", "keep_alive"): + if optional_key in openai_payload: + ollama_payload[optional_key] = openai_payload[optional_key] + + return ollama_payload diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 79a1c0f0dc..2dd49fb8ff 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -151,7 +151,6 @@ def resolve_valves_schema_options( return schema - def extract_frontmatter(content): """ Extract frontmatter as a dictionary from the provided content string. diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index 52539860aa..9d1920651e 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -68,7 +68,7 @@ def convert_ollama_usage_to_openai(data: dict) -> dict: input_tokens = int(data.get("prompt_eval_count", 0)) output_tokens = int(data.get("eval_count", 0)) total_tokens = input_tokens + output_tokens - + return { # Standardized fields "input_tokens": input_tokens, @@ -192,17 +192,29 @@ def convert_embedding_response_ollama_to_openai(response) -> dict: "model": "...", } """ - # Ollama batch-style output + # Ollama batch-style output from /api/embed + # Response format: {"embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]], "model": "..."} if isinstance(response, dict) and "embeddings" in response: openai_data = [] for i, emb in enumerate(response["embeddings"]): - openai_data.append( - { - "object": "embedding", - "embedding": emb.get("embedding"), - "index": emb.get("index", i), - } - ) + # /api/embed returns embeddings as plain float lists + if isinstance(emb, list): + openai_data.append( + { + "object": "embedding", + "embedding": emb, + "index": i, + } + ) + # Also handle dict format for robustness + elif isinstance(emb, dict): + openai_data.append( + { + "object": "embedding", + "embedding": emb.get("embedding"), + "index": emb.get("index", i), + } + ) return { "object": "list", "data": openai_data, diff --git a/backend/open_webui/utils/sanitize.py b/backend/open_webui/utils/sanitize.py index 344a7e08e2..258b6d78fb 100644 --- a/backend/open_webui/utils/sanitize.py +++ b/backend/open_webui/utils/sanitize.py @@ -2,7 +2,9 @@ import re # ANSI escape code pattern - matches all common ANSI sequences # This includes color codes, cursor movement, and other terminal control sequences -ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x1b\([AB]|\x1b[PX^_].*?\x1b\\|\x1b\].*?(?:\x07|\x1b\\)') +ANSI_ESCAPE_PATTERN = re.compile( + r"\x1b\[[0-9;]*[A-Za-z]|\x1b\([AB]|\x1b[PX^_].*?\x1b\\|\x1b\].*?(?:\x07|\x1b\\)" +) def strip_ansi_codes(text: str) -> str: @@ -18,7 +20,7 @@ def strip_ansi_codes(text: str) -> str: - Reset codes: \x1b[0m, \x1b[39m - Cursor movement: \x1b[1A, \x1b[2J, etc. """ - return ANSI_ESCAPE_PATTERN.sub('', text) + return ANSI_ESCAPE_PATTERN.sub("", text) def strip_markdown_code_fences(code: str) -> str: @@ -55,4 +57,3 @@ def sanitize_code(code: str) -> str: code = strip_ansi_codes(code) code = strip_markdown_code_fences(code) return code - diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 4acb2c3718..abc8920884 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -10,7 +10,6 @@ from open_webui.utils.misc import get_last_user_message, get_messages_content from open_webui.config import DEFAULT_RAG_TEMPLATE - log = logging.getLogger(__name__) diff --git a/backend/open_webui/utils/telemetry/instrumentors.py b/backend/open_webui/utils/telemetry/instrumentors.py index 9e251e38ae..17536b9adb 100644 --- a/backend/open_webui/utils/telemetry/instrumentors.py +++ b/backend/open_webui/utils/telemetry/instrumentors.py @@ -29,7 +29,6 @@ from fastapi import status from open_webui.utils.telemetry.constants import SPAN_REDIS_TYPE, SpanAttributes - logger = logging.getLogger(__name__) @@ -60,7 +59,7 @@ def response_hook(span: Span, request: PreparedRequest, response: Response): span.set_status(StatusCode.ERROR if response.status_code >= 400 else StatusCode.OK) -def redis_request_hook(span: Span, instance: Union[Redis|RedisCluster], args, kwargs): +def redis_request_hook(span: Span, instance: Union[Redis | RedisCluster], args, kwargs): """ Redis Request Hook """ @@ -71,7 +70,7 @@ def redis_request_hook(span: Span, instance: Union[Redis|RedisCluster], args, kw # Instead of checking the type, we check if the instance has a nodes_manager attribute. try: db = "" - if hasattr(instance, 'nodes_manager'): + if hasattr(instance, "nodes_manager"): default_node = instance.nodes_manager.default_node if not default_node: return diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 5bb523f836..cb43cf4ef4 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -48,6 +48,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, 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.tools.builtin import ( @@ -77,6 +78,7 @@ from open_webui.tools.builtin import ( search_knowledge_files, query_knowledge_files, view_knowledge_file, + view_skill, ) import copy @@ -149,8 +151,9 @@ def has_tool_server_access( if user_group_ids is None: user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - access_control = server_connection.get("config", {}).get("access_control", None) - return has_access(user.id, "read", access_control, user_group_ids) + server_config = server_connection.get("config", {}) + access_grants = server_config.get("access_grants", []) + return has_access(user.id, "read", access_grants, user_group_ids) async def get_tools( @@ -350,7 +353,13 @@ async def get_tools( headers = include_user_info_headers(headers, user) metadata = extra_params.get("__metadata__", {}) 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") + ) def make_tool_function( function_name, tool_server_data, headers @@ -414,9 +423,8 @@ def get_builtin_tools( # Helper to get model capabilities (defaults to True if not specified) def get_model_capability(name: str, default: bool = True) -> bool: - return ( - (model.get("info", {}).get("meta", {}).get("capabilities") or {}) - .get(name, default) + return (model.get("info", {}).get("meta", {}).get("capabilities") or {}).get( + name, default ) # Helper to check if a builtin tool category is enabled via meta.builtinTools @@ -489,13 +497,17 @@ def get_builtin_tools( builtin_functions.append(execute_code) # Notes tools - search, view, create, and update user's notes (if builtin category enabled AND notes enabled globally) - if is_builtin_tool_enabled("notes") and getattr(request.app.state.config, "ENABLE_NOTES", False): + if is_builtin_tool_enabled("notes") and getattr( + request.app.state.config, "ENABLE_NOTES", False + ): builtin_functions.extend( [search_notes, view_note, write_note, replace_note_content] ) # Channels tools - search channels and messages (if builtin category enabled AND channels enabled globally) - if is_builtin_tool_enabled("channels") and getattr(request.app.state.config, "ENABLE_CHANNELS", False): + if is_builtin_tool_enabled("channels") and getattr( + request.app.state.config, "ENABLE_CHANNELS", False + ): builtin_functions.extend( [ search_channels, @@ -505,6 +517,10 @@ def get_builtin_tools( ] ) + # Skills tools - view_skill allows model to load full skill instructions on demand + if extra_params.get("__skill_ids__"): + builtin_functions.append(view_skill) + for func in builtin_functions: callable = get_async_tool_function_and_apply_extra_params( func, diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py new file mode 100644 index 0000000000..6e62dd5416 --- /dev/null +++ b/backend/open_webui/utils/validate.py @@ -0,0 +1,38 @@ +"""Validation utilities for user-supplied input.""" + +# Known static asset paths used as default profile images +_ALLOWED_STATIC_PATHS = ( + "/user.png", + "/static/favicon.png", +) + + +def validate_profile_image_url(url: str) -> str: + """ + Pydantic-compatible validator for profile image URLs. + + Allowed formats: + - Empty string (falls back to default avatar) + - data:image/* URIs (base64-encoded uploads from the frontend) + - Known static asset paths (/user.png, /static/favicon.png) + + Returns the url unchanged if valid, raises ValueError otherwise. + """ + if not url: + return url + + _ALLOWED_DATA_PREFIXES = ( + "data:image/png", + "data:image/jpeg", + "data:image/gif", + "data:image/webp", + ) + if any(url.startswith(prefix) for prefix in _ALLOWED_DATA_PREFIXES): + return url + + if url in _ALLOWED_STATIC_PATHS: + return url + + raise ValueError( + "Invalid profile image URL: only data URIs and default avatars are allowed." + ) diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index b5cf822c5d..532a6cd714 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -21,6 +21,7 @@ async-timeout aiocache aiofiles starlette-compress==1.7.0 +Brotli==1.1.0 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 957fb6ae27..5c1f044d1d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,6 +18,7 @@ async-timeout aiocache aiofiles starlette-compress==1.7.0 +Brotli==1.1.0 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 python-mimeparse==2.0.0 diff --git a/package-lock.json b/package-lock.json index 057a856e2a..0a85159d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.7.2", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.7.2", + "version": "0.8.0", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index bc71db855e..673bad49cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.7.2", + "version": "0.8.0", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/pyproject.toml b/pyproject.toml index eeda46681f..d240449dff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "aiocache", "aiofiles", "starlette-compress==1.7.0", + "Brotli==1.1.0", "httpx[socks,http2,zstd,cli,brotli]==0.28.1", "starsessions[redis]==2.2.1", "python-mimeparse==2.0.0", diff --git a/src/lib/apis/analytics/index.ts b/src/lib/apis/analytics/index.ts index f03729937c..6bab2cbf81 100644 --- a/src/lib/apis/analytics/index.ts +++ b/src/lib/apis/analytics/index.ts @@ -1,317 +1,319 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; export const getModelAnalytics = async ( - token: string = '', - startDate: number | null = null, - endDate: number | null = null, - groupId: string | null = null + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + groupId: string | null = null ) => { - let error = null; + let error = null; - const searchParams = new URLSearchParams(); - if (startDate) searchParams.append('start_date', startDate.toString()); - if (endDate) searchParams.append('end_date', endDate.toString()); - if (groupId) searchParams.append('group_id', groupId); + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (groupId) searchParams.append('group_id', groupId); - const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return null; - }); + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); - if (error) { - throw error; - } + if (error) { + throw error; + } - return res; + return res; }; export const getUserAnalytics = async ( - token: string = '', - startDate: number | null = null, - endDate: number | null = null, - limit: number = 50, - groupId: string | null = null + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + limit: number = 50, + groupId: string | null = null ) => { - let error = null; + let error = null; - const searchParams = new URLSearchParams(); - if (startDate) searchParams.append('start_date', startDate.toString()); - if (endDate) searchParams.append('end_date', endDate.toString()); - if (limit) searchParams.append('limit', limit.toString()); - if (groupId) searchParams.append('group_id', groupId); + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (limit) searchParams.append('limit', limit.toString()); + if (groupId) searchParams.append('group_id', groupId); - const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return null; - }); + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); - if (error) { - throw error; - } + if (error) { + throw error; + } - return res; + return res; }; export const getMessages = async ( - token: string = '', - modelId: string | null = null, - userId: string | null = null, - chatId: string | null = null, - startDate: number | null = null, - endDate: number | null = null, - skip: number = 0, - limit: number = 50 + token: string = '', + modelId: string | null = null, + userId: string | null = null, + chatId: string | null = null, + startDate: number | null = null, + endDate: number | null = null, + skip: number = 0, + limit: number = 50 ) => { - let error = null; + let error = null; - const searchParams = new URLSearchParams(); - if (modelId) searchParams.append('model_id', modelId); - if (userId) searchParams.append('user_id', userId); - if (chatId) searchParams.append('chat_id', chatId); - if (startDate) searchParams.append('start_date', startDate.toString()); - if (endDate) searchParams.append('end_date', endDate.toString()); - if (skip) searchParams.append('skip', skip.toString()); - if (limit) searchParams.append('limit', limit.toString()); + const searchParams = new URLSearchParams(); + if (modelId) searchParams.append('model_id', modelId); + if (userId) searchParams.append('user_id', userId); + if (chatId) searchParams.append('chat_id', chatId); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (skip) searchParams.append('skip', skip.toString()); + if (limit) searchParams.append('limit', limit.toString()); - const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/messages?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return null; - }); + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/messages?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); - if (error) { - throw error; - } + if (error) { + throw error; + } - return res; + return res; }; export const getSummary = async ( - token: string = '', - startDate: number | null = null, - endDate: number | null = null, - groupId: string | null = null + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + groupId: string | null = null ) => { - let error = null; + let error = null; - const searchParams = new URLSearchParams(); - if (startDate) searchParams.append('start_date', startDate.toString()); - if (endDate) searchParams.append('end_date', endDate.toString()); - if (groupId) searchParams.append('group_id', groupId); + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (groupId) searchParams.append('group_id', groupId); - const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/summary?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return null; - }); + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/summary?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); - if (error) { - throw error; - } + if (error) { + throw error; + } - return res; + return res; }; export const getDailyStats = async ( - token: string = '', - startDate: number | null = null, - endDate: number | null = null, - granularity: 'hourly' | 'daily' = 'daily', - groupId: string | null = null + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + granularity: 'hourly' | 'daily' = 'daily', + groupId: string | null = null ) => { - let error = null; + let error = null; - const searchParams = new URLSearchParams(); - if (startDate) searchParams.append('start_date', startDate.toString()); - if (endDate) searchParams.append('end_date', endDate.toString()); - searchParams.append('granularity', granularity); - if (groupId) searchParams.append('group_id', groupId); + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + searchParams.append('granularity', granularity); + if (groupId) searchParams.append('group_id', groupId); - const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return null; - }); + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); - if (error) { - throw error; - } + if (error) { + throw error; + } - return res; + return res; }; export const getTokenUsage = async ( - token: string = '', - startDate: number | null = null, - endDate: number | null = null, - groupId: string | null = null + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + groupId: string | null = null ) => { - let error = null; + let error = null; - const searchParams = new URLSearchParams(); - if (startDate) searchParams.append('start_date', startDate.toString()); - if (endDate) searchParams.append('end_date', endDate.toString()); - if (groupId) searchParams.append('group_id', groupId); + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (groupId) searchParams.append('group_id', groupId); - const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/tokens?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return null; - }); + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/tokens?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); - if (error) { - throw error; - } + if (error) { + throw error; + } - return res; + return res; }; export const getModelChats = async ( - token: string = '', - modelId: string, - startDate: number | null = null, - endDate: number | null = null, - skip: number = 0, - limit: number = 50 + token: string = '', + modelId: string, + startDate: number | null = null, + endDate: number | null = null, + skip: number = 0, + limit: number = 50 ) => { - let error = null; + let error = null; - const searchParams = new URLSearchParams(); - if (startDate) searchParams.append('start_date', startDate.toString()); - if (endDate) searchParams.append('end_date', endDate.toString()); - if (skip) searchParams.append('skip', skip.toString()); - if (limit) searchParams.append('limit', limit.toString()); + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (skip) searchParams.append('skip', skip.toString()); + if (limit) searchParams.append('limit', limit.toString()); - const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/chats?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return null; - }); + const res = await fetch( + `${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/chats?${searchParams.toString()}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); - if (error) { - throw error; - } + if (error) { + throw error; + } - return res; + return res; }; -export const getModelOverview = async ( - token: string = '', - modelId: string, - days: number = 30 -) => { - let error = null; +export const getModelOverview = async (token: string = '', modelId: string, days: number = 30) => { + let error = null; - const searchParams = new URLSearchParams(); - searchParams.append('days', days.toString()); + const searchParams = new URLSearchParams(); + searchParams.append('days', days.toString()); - const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/overview?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return null; - }); + const res = await fetch( + `${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/overview?${searchParams.toString()}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); - if (error) { - throw error; - } + if (error) { + throw error; + } - return res; + return res; }; diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index bfe7a16386..e16746707a 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -255,11 +255,7 @@ export const getArchivedChatList = async ( })); }; -export const getSharedChatList = async ( - token: string = '', - page: number = 1, - filter?: object -) => { +export const getSharedChatList = async (token: string = '', page: number = 1, filter?: object) => { let error = null; const searchParams = new URLSearchParams(); diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 92c28e1d9d..f15acc8d08 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -864,7 +864,8 @@ export const generateQueries = async ( model: string, messages: object[], prompt: string, - type: string = 'web_search' + type: string = 'web_search', + chat_id?: string ) => { let error = null; @@ -879,7 +880,8 @@ export const generateQueries = async ( model: model, messages: messages, prompt: prompt, - type: type + type: type, + ...(chat_id && { chat_id: chat_id }) }) }) .then(async (res) => { @@ -933,7 +935,8 @@ export const generateAutoCompletion = async ( model: string, prompt: string, messages?: object[], - type: string = 'search query' + type: string = 'search query', + chat_id?: string ) => { const controller = new AbortController(); let error = null; @@ -951,7 +954,8 @@ export const generateAutoCompletion = async ( prompt: prompt, ...(messages && { messages: messages }), type: type, - stream: false + stream: false, + ...(chat_id && { chat_id: chat_id }) }) }) .then(async (res) => { @@ -1675,7 +1679,7 @@ export interface ModelMeta { profile_image_url?: string; } -export interface ModelParams { } +export interface ModelParams {} export type GlobalModelConfig = ModelConfig[]; diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index 4c7c90484c..f314bae634 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -289,6 +289,39 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl return res; }; +export const updateKnowledgeAccessGrants = async ( + token: string, + id: string, + accessGrants: any[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const addFileToKnowledgeById = async (token: string, id: string, fileId: string) => { let error = null; diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index d03a83e9ca..42e77c0afa 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -281,6 +281,35 @@ export const updateModelById = async (token: string, id: string, model: object) return res; }; +export const updateModelAccessGrants = async (token: string, id: string, accessGrants: any[]) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ id, access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteModelById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 341ced57ec..07e249a889 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -253,6 +253,35 @@ export const updateNoteById = async (token: string, id: string, note: NoteItem) return res; }; +export const updateNoteAccessGrants = async (token: string, id: string, accessGrants: any[]) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteNoteById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/prompts/index.ts b/src/lib/apis/prompts/index.ts index c227c9f713..1fd311c76f 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -1,16 +1,16 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; type PromptItem = { - id?: string; // Prompt ID + id?: string; // Prompt ID command: string; - name: string; // Changed from title + name: string; // Changed from title content: string; data?: object | null; meta?: object | null; access_grants?: object[]; - version_id?: string | null; // Active version - commit_message?: string | null; // For history tracking - is_production?: boolean; // Whether to set new version as production + version_id?: string | null; // Active version + commit_message?: string | null; // For history tracking + is_production?: boolean; // Whether to set new version as production }; type PromptHistoryItem = { @@ -196,7 +196,6 @@ export const getPromptItems = async ( }; export const getPromptList = async (token: string = '') => { - let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list`, { @@ -428,6 +427,39 @@ export const deletePromptById = async (token: string, promptId: string) => { return res; }; +export const updatePromptAccessGrants = async ( + token: string, + promptId: string, + accessGrants: any[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + //////////////////////////// // Prompt History APIs //////////////////////////// @@ -439,17 +471,14 @@ export const getPromptHistory = async ( ): Promise => { let error = null; - const res = await fetch( - `${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history?page=${page}`, - { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history?page=${page}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` } - ) + }) .then(async (res) => { if (!res.ok) throw await res.json(); return res.json(); @@ -474,17 +503,14 @@ export const deletePromptHistoryVersion = async ( ): Promise => { let error = null; - const res = await fetch( - `${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history/${historyId}`, - { - method: 'DELETE', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history/${historyId}`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` } - ) + }) .then(async (res) => { if (!res.ok) throw await res.json(); return res.json(); @@ -509,17 +535,14 @@ export const getPromptHistoryEntry = async ( ): Promise => { let error = null; - const res = await fetch( - `${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history/${historyId}`, - { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history/${historyId}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` } - ) + }) .then(async (res) => { if (!res.ok) throw await res.json(); return res.json(); diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts new file mode 100644 index 0000000000..24139fa042 --- /dev/null +++ b/src/lib/apis/skills/index.ts @@ -0,0 +1,321 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const createNewSkill = async (token: string, skill: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...skill + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSkills = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSkillList = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/list`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSkillItems = async ( + token: string = '', + query: string | null = null, + viewOption: string | null = null, + page: number | null = null +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + if (viewOption) searchParams.append('view_option', viewOption); + if (page) searchParams.append('page', page.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/list?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const exportSkills = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/export`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateSkillById = async (token: string, id: string, skill: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...skill + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateSkillAccessGrants = async (token: string, id: string, accessGrants: any[]) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + access_grants: accessGrants + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const toggleSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/toggle`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/delete`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/tasks/index.ts b/src/lib/apis/tasks/index.ts index 83299b843b..dab6090fde 100644 --- a/src/lib/apis/tasks/index.ts +++ b/src/lib/apis/tasks/index.ts @@ -1,14 +1,14 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; export const checkActiveChats = async (token: string, chatIds: string[]) => { - const res = await fetch(`${WEBUI_API_BASE_URL}/tasks/active/chats`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ chat_ids: chatIds }) - }); - if (!res.ok) throw await res.json(); - return res.json(); + const res = await fetch(`${WEBUI_API_BASE_URL}/tasks/active/chats`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ chat_ids: chatIds }) + }); + if (!res.ok) throw await res.json(); + return res.json(); }; diff --git a/src/lib/apis/tools/index.ts b/src/lib/apis/tools/index.ts index 2038e46ac6..5d26e50fee 100644 --- a/src/lib/apis/tools/index.ts +++ b/src/lib/apis/tools/index.ts @@ -225,6 +225,35 @@ export const updateToolById = async (token: string, id: string, tool: object) => return res; }; +export const updateToolAccessGrants = async (token: string, id: string, accessGrants: any[]) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/id/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteToolById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index ad669c3eb9..91b63338de 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -300,7 +300,6 @@ export const updateUserSettings = async (token: string, settings: object) => { return res; }; - export const getUserInfoById = async (token: string, userId: string) => { let error = null; diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 764e2259bd..5b64b692a7 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -48,7 +48,7 @@ let headers = ''; let functionNameFilterList = ''; - let accessControl = {}; + let accessGrants = []; let id = ''; let name = ''; @@ -149,7 +149,7 @@ key, config: { enable: enable, - access_control: accessControl + access_grants: accessGrants }, info: { id, @@ -206,7 +206,7 @@ if (data.config) { enable = data.config.enable ?? true; - accessControl = data.config.access_control ?? {}; + accessGrants = data.config.access_grants ?? []; } toast.success($i18n.t('Import successful')); @@ -305,7 +305,7 @@ config: { enable: enable, function_name_filter_list: functionNameFilterList, - access_control: accessControl + access_grants: accessGrants }, info: { id: id, @@ -339,7 +339,7 @@ enable = true; functionNameFilterList = ''; - accessControl = null; + accessGrants = []; }; const init = () => { @@ -363,7 +363,7 @@ enable = connection.config?.enable ?? true; functionNameFilterList = connection.config?.function_name_filter_list ?? ''; - accessControl = connection.config?.access_control ?? null; + accessGrants = connection.config?.access_grants ?? []; } }; @@ -819,7 +819,7 @@
- +
{/if} diff --git a/src/lib/components/NotificationToast.svelte b/src/lib/components/NotificationToast.svelte index d232009b21..5a40b85b70 100644 --- a/src/lib/components/NotificationToast.svelte +++ b/src/lib/components/NotificationToast.svelte @@ -50,7 +50,10 @@ (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); // Skip if clicking the close button - if (closeButtonElement && (e.target === closeButtonElement || closeButtonElement.contains(e.target as Node))) { + if ( + closeButtonElement && + (e.target === closeButtonElement || closeButtonElement.contains(e.target as Node)) + ) { return; } diff --git a/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte b/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte index 642ac49a68..c9eb9cf3d7 100644 --- a/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte +++ b/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte @@ -32,7 +32,13 @@ let loadingOverview = false; // Chats tab state - let chatList: Array<{ id: string; title: string; updated_at: number; user_id?: string; user_name?: string }> = []; + let chatList: Array<{ + id: string; + title: string; + updated_at: number; + user_id?: string; + user_name?: string; + }> = []; let chatListLoading = false; let allChatsLoaded = false; const PAGE_SIZE = 50; @@ -76,7 +82,14 @@ chatList = []; allChatsLoaded = false; try { - const res = await getModelChats(localStorage.token, model.id, startDate, endDate, 0, PAGE_SIZE); + const res = await getModelChats( + localStorage.token, + model.id, + startDate, + endDate, + 0, + PAGE_SIZE + ); const chats = res?.chats ?? []; chatList = chats.map((c: any) => ({ id: c.chat_id, @@ -99,7 +112,14 @@ chatListLoading = true; try { const skip = chatList.length; - const res = await getModelChats(localStorage.token, model.id, startDate, endDate, skip, PAGE_SIZE); + const res = await getModelChats( + localStorage.token, + model.id, + startDate, + endDate, + skip, + PAGE_SIZE + ); const chats = res?.chats ?? []; const newChats = chats.map((c: any) => ({ id: c.chat_id, @@ -157,14 +177,14 @@ {$i18n.t('Overview')} {#if $config?.features?.enable_admin_chat_access} - + {/if} @@ -175,10 +195,10 @@
-
- {$i18n.t('Feedback Activity')} -
-
+
+ {$i18n.t('Feedback Activity')} +
+
diff --git a/src/lib/components/admin/Analytics/ChartLine.svelte b/src/lib/components/admin/Analytics/ChartLine.svelte index 5c3ffabdf3..6f2f68f39a 100644 --- a/src/lib/components/admin/Analytics/ChartLine.svelte +++ b/src/lib/components/admin/Analytics/ChartLine.svelte @@ -83,7 +83,11 @@ {@const labelCount = Math.min(7, data.length)} {@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1} {@const isHourly = data[0]?.date?.includes(':')} - {@const dateFormat = isHourly ? 'h A' : period === 'year' || period === 'all' ? 'M/D/YY' : 'M/D'} + {@const dateFormat = isHourly + ? 'h A' + : period === 'year' || period === 'all' + ? 'M/D/YY' + : 'M/D'}
{#each Array(labelCount) as _, i} {@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)} diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index 9388761636..98877d1d43 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -1,7 +1,13 @@ -
+
{$i18n.t('Analytics')}
@@ -208,27 +221,54 @@ {#if !loading}
- {summary.total_messages.toLocaleString()} {$i18n.t('messages')} + {summary.total_messages.toLocaleString()} + {$i18n.t('messages')} - {formatNumber(totalTokens.total)} {$i18n.t('tokens')} + {formatNumber(totalTokens.total)} + {$i18n.t('tokens')} - {summary.total_chats.toLocaleString()} {$i18n.t('chats')} - {summary.total_users} {$i18n.t('users')} + {summary.total_chats.toLocaleString()} + {$i18n.t('chats')} + {summary.total_users} + {$i18n.t('users')}
{#if dailyStats.length > 1} - {@const allModels = [...new Set(dailyStats.flatMap(d => Object.keys(d.models || {})))]} + {@const allModels = [...new Set(dailyStats.flatMap((d) => Object.keys(d.models || {})))]} {@const topModels = allModels.slice(0, 8)} - {@const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']} - {@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', 'all': 'all' }} + {@const chartColors = [ + '#3b82f6', + '#10b981', + '#f59e0b', + '#ef4444', + '#8b5cf6', + '#ec4899', + '#06b6d4', + '#84cc16' + ]} + {@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', all: 'all' }}
{$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : 'Daily Messages')}
- - {#if modelDirection === 'asc'}{:else}{/if} + {#if modelDirection === 'asc'}{:else}{/if} {:else} @@ -278,7 +320,9 @@ {$i18n.t('Messages')} {#if modelOrderBy === 'count'} - {#if modelDirection === 'asc'}{:else}{/if} + {#if modelDirection === 'asc'}{:else}{/if} {:else} @@ -292,9 +336,12 @@ {#each sortedModels as model, idx (model.model_id)} { selectedModel = { id: model.model_id, name: model.name }; showModelModal = true; }} - > + class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" + on:click={() => { + selectedModel = { id: model.model_id, name: model.name }; + showModelModal = true; + }} + > {idx + 1}
@@ -307,14 +354,22 @@
{model.count.toLocaleString()} - {formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)} + {formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)} - {totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}% + {totalModelMessages > 0 + ? ((model.count / totalModelMessages) * 100).toFixed(1) + : 0}% {/each} {#if sortedModels.length === 0} - {$i18n.t('No data')} + {$i18n.t('No data')} {/if} @@ -340,7 +395,9 @@ {$i18n.t('User')} {#if userOrderBy === 'name'} - {#if userDirection === 'asc'}{:else}{/if} + {#if userDirection === 'asc'}{:else}{/if} {:else} @@ -356,7 +413,9 @@ {$i18n.t('Messages')} {#if userOrderBy === 'count'} - {#if userDirection === 'asc'}{:else}{/if} + {#if userDirection === 'asc'}{:else}{/if} {:else} @@ -377,7 +436,9 @@ alt={user.name || 'User'} class="size-5 rounded-full object-cover shrink-0" /> - {user.name || user.email || user.user_id.substring(0, 8)} + {user.name || user.email || user.user_id.substring(0, 8)}
{user.count.toLocaleString()} @@ -385,7 +446,11 @@ {/each} {#if sortedUsers.length === 0} - {$i18n.t('No data')} + {$i18n.t('No data')} {/if} diff --git a/src/lib/components/admin/Analytics/ModelUsage.svelte b/src/lib/components/admin/Analytics/ModelUsage.svelte index d7212f721a..d24e798c88 100644 --- a/src/lib/components/admin/Analytics/ModelUsage.svelte +++ b/src/lib/components/admin/Analytics/ModelUsage.svelte @@ -41,9 +41,7 @@ $: sortedModels = [...modelStats].sort((a, b) => { if (orderBy === 'name') { - return direction === 'asc' - ? a.name.localeCompare(b.name) - : b.name.localeCompare(a.name); + return direction === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); } return direction === 'asc' ? a.count - b.count : b.count - a.count; }); @@ -53,16 +51,22 @@ onMount(loadAnalytics); -
+
{$i18n.t('Model Usage')} {totalMessages} {$i18n.t('messages')}
-
+
{#if loading} -
+
{/if} @@ -70,7 +74,11 @@ {#if !modelStats.length && !loading}
{$i18n.t('No data found')}
{:else if modelStats.length} - +
@@ -82,7 +90,9 @@
{$i18n.t('Model')} {#if orderBy === 'name'} - {#if direction === 'asc'}{:else}{/if} + {#if direction === 'asc'}{:else}{/if} {:else} {/if} @@ -96,7 +106,9 @@
{$i18n.t('Messages')} {#if orderBy === 'count'} - {#if direction === 'asc'}{:else}{/if} + {#if direction === 'asc'}{:else}{/if} {:else} {/if} @@ -107,7 +119,9 @@
{#each sortedModels as model, idx (model.model_id)} - + diff --git a/src/lib/components/admin/Analytics/UserUsage.svelte b/src/lib/components/admin/Analytics/UserUsage.svelte index 015ba195d1..09be24e426 100644 --- a/src/lib/components/admin/Analytics/UserUsage.svelte +++ b/src/lib/components/admin/Analytics/UserUsage.svelte @@ -46,16 +46,22 @@ onMount(loadAnalytics); -
+
{$i18n.t('User Activity')} {userStats.length} {$i18n.t('users')}
-
+
{#if loading} -
+
{/if} @@ -63,7 +69,11 @@ {#if !userStats.length && !loading}
{$i18n.t('No data found')}
{:else if userStats.length} -
#
{idx + 1}
+
@@ -75,7 +85,9 @@
{$i18n.t('User')} {#if orderBy === 'user_id'} - {#if direction === 'asc'}{:else}{/if} + {#if direction === 'asc'}{:else}{/if} {:else} {/if} @@ -89,7 +101,9 @@
{$i18n.t('Messages')} {#if orderBy === 'count'} - {#if direction === 'asc'}{:else}{/if} + {#if direction === 'asc'}{:else}{/if} {:else} {/if} @@ -100,7 +114,9 @@
{#each sortedUsers as user, idx (user.user_id)} - + diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index ccf1735431..1dedb94f05 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -303,10 +303,12 @@
{#if feedback.data?.sibling_model_ids} -
- {feedback.data?.model_id} -
-
+
+ {feedback.data?.model_id} +
+
@@ -323,10 +325,12 @@ {:else} -
- {feedback.data?.model_id} -
-
+
+ {feedback.data?.model_id} +
+ {/if}
diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte index 0dd501a153..abe0f952e4 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -183,7 +183,9 @@ class="size-5 rounded-full object-cover" /> - {model.name} + {model.name} diff --git a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte index ef78d2352e..2726dd13b4 100644 --- a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte +++ b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte @@ -44,7 +44,7 @@ let modelIds = []; let filterMode = 'include'; - let accessControl = {}; + let accessGrants = []; let imageInputElement; let loading = false; @@ -83,7 +83,7 @@ description: description || null, model_ids: modelIds.length > 0 ? modelIds : null, filter_mode: modelIds.length > 0 ? (filterMode ? filterMode : null) : null, - access_control: accessControl + access_grants: accessGrants } }; @@ -107,7 +107,7 @@ description = model.meta.description; modelIds = model.meta.model_ids || []; filterMode = model.meta?.filter_mode ?? 'include'; - accessControl = 'access_control' in model.meta ? model.meta.access_control : {}; + accessGrants = model.meta.access_grants ?? []; } }; @@ -293,7 +293,7 @@
- +

diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index e0e7dff029..2762d3a112 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -44,6 +44,7 @@ import { flyAndScale } from '$lib/utils/transitions'; import Dropdown from '$lib/components/common/Dropdown.svelte'; import AdminViewSelector from './Models/AdminViewSelector.svelte'; + import Pagination from '$lib/components/common/Pagination.svelte'; let shiftKey = false; @@ -64,6 +65,9 @@ let viewOption = ''; // '' = All, 'enabled', 'disabled', 'visible', 'hidden' + const perPage = 30; + let currentPage = 1; + $: if (models) { filteredModels = models .filter((m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase())) @@ -81,6 +85,10 @@ let searchValue = ''; + $: if (searchValue || viewOption !== undefined) { + currentPage = 1; + } + const enableAllHandler = async () => { const modelsToEnable = filteredModels.filter((m) => !(m.is_active ?? true)); // Optimistic UI update @@ -96,7 +104,9 @@ modelsToDisable.forEach((m) => (m.is_active = false)); models = models; // Sync with server - await Promise.all(modelsToDisable.map((model) => toggleModelById(localStorage.token, model.id))); + await Promise.all( + modelsToDisable.map((model) => toggleModelById(localStorage.token, model.id)) + ); }; const downloadModels = async (models) => { @@ -420,7 +430,7 @@
{#if filteredModels.length > 0} - {#each filteredModels as model, modelIdx (`${model.id}-${modelIdx}`)} + {#each filteredModels.slice((currentPage - 1) * perPage, currentPage * perPage) as model, modelIdx (`${model.id}-${modelIdx}`)}
+
{$i18n.t('Yandex Web Search URL')} @@ -769,20 +769,22 @@
-
{$i18n.t('Yandex Web Search config')}
+
{$i18n.t('Yandex Web Search config')}
- -
#
{idx + 1}