diff --git a/CHANGELOG.md b/CHANGELOG.md index a90d38918d..b6a6674982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,109 @@ 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.5] - 2026-02-23 + +### Added + +- ⌨️ **Voice dictation shortcut.** Users can now toggle voice dictation using Cmd+Shift+L (or Ctrl+Shift+L on Windows/Linux), making it faster to start and stop dictation without clicking the microphone button. + +### Fixed + +- 🚫 **Model access KeyError fix.** The /api/models endpoint no longer crashes with a 500 error when models have incomplete info metadata missing the user_id field (e.g. models using global default metadata). +- 🔄 **Frontend initialization resilience.** The app layout now gracefully handles individual API failures during initialization (getModels, getBanners, getTools, getUserSettings, setToolServers) instead of blocking the entire page load when any single call fails. +- 🛡️ **Backend config null safety.** Language detection during app initialization no longer crashes when the backend config fetch fails, preventing a secondary cause of infinite loading. + +## [0.8.4] - 2026-02-23 + +### Added + +- 🛜 **Provider URL suggestions.** The connection form now displays a dropdown with suggested URLs for popular AI providers, making it easier to configure connections. [Commit](https://github.com/open-webui/open-webui/commit/49c36238d01aaff5466344ecd316a6dd3edd74a3) +- ☁️ **Anthropic model fetching.** The system now properly fetches available models from the Anthropic API, ensuring all Anthropic models are accessible. [Commit](https://github.com/open-webui/open-webui/commit/e9d852545cc17f0eeb8bdcfa77575a80fed8706d) +- 💡 **No models prompt.** When no models are available, a helpful prompt now guides users to manage their provider connections. [Commit](https://github.com/open-webui/open-webui/commit/a0195cd5ae9b9915295839cd0a5fbac5a1b0bfa2) +- ⚙️ **Connection enable/disable toggles.** Individual provider connections can now be enabled or disabled from both admin and user settings. [Commit](https://github.com/open-webui/open-webui/commit/990c638f6cf91507b61898f454c26f9516114c36) +- ⏸️ **Prompt enable/disable toggle.** Users can now enable or disable prompts directly from the prompts list using a toggle switch, without needing to delete and recreate them. Inactive prompts display an "Inactive" badge and are still visible in the list. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761) +- 🗑️ **Memory deletion.** Agents can now delete specific memories that are no longer relevant, duplicated, or incorrect, giving better control over stored memory content. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761) +- 📋 **Memory listing.** Agents can now list all stored memories, enabling them to identify which memories to manage or delete based on the complete memory inventory. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761) +- 📦 **Auto pip install toggle.** Administrators can now disable automatic pip package installation from function frontmatter requirements using the ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS environment variable, providing more control over function dependency management. [Commit](https://github.com/open-webui/open-webui/commit/8bfab327ec5f635f9fe93c26efd198712ff7116d) +- 🔗 **Anthropic Messages API proxy.** A new API endpoint now supports the Anthropic Messages API format, allowing tools like Claude Code to authenticate through Open WebUI and access configured models. Tool calls are now properly supported in streaming responses with correct multi-block indexing, and error status from tools is propagated correctly. The endpoint converts requests to OpenAI format internally, routes them through the existing chat pipeline, and returns responses in Anthropic format. [#21390](https://github.com/open-webui/open-webui/discussions/21390), [Commit](https://github.com/open-webui/open-webui/commit/91a0301c9e22e93295a7c471d83592a802560795), [Commit](https://github.com/open-webui/open-webui/commit/a9312d25373d3aa161788598f87180b8db11c5b6) +- 👥 **Multi-device OAuth sessions.** Users can now stay logged in on multiple devices simultaneously with OAuth, as re-logging in no longer terminates existing sessions. The oldest sessions are automatically pruned when the session limit is exceeded. [#21647](https://github.com/open-webui/open-webui/issues/21647), [Commit](https://github.com/open-webui/open-webui/commit/ae05586fdabf318d551b53ede41575355d3b9e2b) +- 🔐 **OAuth group default share setting.** Administrators can now configure the default sharing setting for OAuth-created groups using the OAUTH_GROUP_DEFAULT_SHARE environment variable, allowing control over whether new groups default to private or shared with members. [#21679](https://github.com/open-webui/open-webui/pull/21679), [Commit](https://github.com/open-webui/open-webui/commit/4b9f821b58007d4efa4aa16a4995b23126e08a88) +- 🔧 **Knowledge base import behavior.** The web content import endpoint now supports a configurable overwrite flag, allowing users to add multiple URLs to the same knowledge base instead of replacing existing content. [#21613](https://github.com/open-webui/open-webui/pull/21613), [#21336](https://github.com/open-webui/open-webui/issues/21336), [Commit](https://github.com/open-webui/open-webui/commit/4bef69cc6344ff809090441aa6bced573a2aa838) +- 🧩 **Skill JSON import support.** Skills can now be imported from both JSON and Markdown files. [#21511](https://github.com/open-webui/open-webui/issues/21511) +- 🔍 **You.com web search provider.** A new web search provider option for You.com is now available, giving users another search engine choice for web-enabled models. The You.com provider enriches search results by including both descriptions and snippets for better context. [#21599](https://github.com/open-webui/open-webui/pull/21599) +- 🚀 **Message list performance.** Loading conversation history when sending messages is now significantly faster, improving response latency before the model starts generating. This also speeds up chat search and RAG context building. [#21588](https://github.com/open-webui/open-webui/pull/21588) +- 🎯 **Concurrent embedding request control.** Administrators can now control the maximum number of concurrent embedding API requests using the RAG_EMBEDDING_CONCURRENT_REQUESTS environment variable, helping manage API rate limits while maintaining embedding performance. [#21662](https://github.com/open-webui/open-webui/pull/21662), [Commit](https://github.com/open-webui/open-webui/commit/5d4547f934b6fbe751bb2041f9597fe11ddf8e43) +- ⚡ **Message upsert optimization.** Loading chat data during message saving is now significantly faster by eliminating a redundant database call that occurred on every message upsert, which happens many times during streaming responses. [#21592](https://github.com/open-webui/open-webui/pull/21592) +- ⚡ **Message send optimization.** Loading chat data during message sending is now significantly faster by eliminating unnecessary full conversation history loads. The system now uses targeted queries that fetch only the needed data instead of loading entire chat objects with all message history. [#21596](https://github.com/open-webui/open-webui/pull/21596) +- 🚀 **Tag filtering optimization.** Chat search with tag filtering now uses more efficient database queries, making filtered searches significantly faster. [Commit](https://github.com/open-webui/open-webui/commit/139f02a9d9fa2ffffcc96aa0de8af8ef51b6bcf2) +- ⚡ **Shared chat loading optimization.** The shared chats endpoint now loads only the needed columns instead of the full conversation history, making shared chat listings significantly faster. [#21614](https://github.com/open-webui/open-webui/pull/21614) +- 🗂️ **Archived and pinned chat loading.** Loading archived and pinned chat lists is now significantly faster by loading only the needed columns instead of full conversation data. [#21591](https://github.com/open-webui/open-webui/pull/21591) +- 💨 **Chat title query optimization.** Retrieving chat titles now queries only the title column instead of the entire conversation history, making title lookups significantly faster and reducing database load. [#21590](https://github.com/open-webui/open-webui/pull/21590) +- 🗄️ **Batch access grants for multiple resources.** Loading channels, knowledge bases, models, notes, prompts, skills, and tools now uses batch database queries for access grants instead of individual queries per item, significantly reducing database load. For 30 items, this reduces approximately 31 queries to just 3. [#21616](https://github.com/open-webui/open-webui/pull/21616) +- 📋 **Notes list payload optimization.** Notes list and search endpoints now return only a 200-character preview instead of the full note content, reducing response payload from ~167 MB to ~10 KB for 60 notes and eliminating N+1 queries for access grants. The Notes tab now loads in seconds instead of tens of seconds. [#21549](https://github.com/open-webui/open-webui/pull/21549) +- ⚡ **Tools list performance.** Loading the tools list is now significantly faster by deferring content and specs fields from database queries, and using cached tool modules instead of reloading them for each request. [Commit](https://github.com/open-webui/open-webui/commit/b48594a16680cc77921a4ed1a11ffa07df7edc60) +- 📝 **Group description display.** The admin groups list now shows each group's description, making it easier for administrators to identify groups at a glance. +- 🏷️ **Sort by dropdown.** Administrators can now sort groups using a dropdown menu with options for Name or Members, replacing the previous clickable column headers. +- 📶 **Admin groups list sorting.** The Group and Users columns in the admin groups list are now clickable for sorting, allowing administrators to sort groups alphabetically by name or numerically by member count. [#21692](https://github.com/open-webui/open-webui/pull/21692) +- 🔽 **Rich UI auto-scroll.** The view now automatically scrolls to action-generated Rich UI content once it renders, ensuring users can see the results without manually scrolling. [#21698](https://github.com/open-webui/open-webui/pull/21698), [#21482](https://github.com/open-webui/open-webui/discussions/21482) +- 📊 **Admin analytics toggle.** Administrators can now enable or disable the analytics feature using the ENABLE_ADMIN_ANALYTICS environment variable, giving more control over available admin features. [#21651](https://github.com/open-webui/open-webui/pull/21651), [Commit](https://github.com/open-webui/open-webui/commit/35598b8017557258b8c9ee3469d320adb0140751) +- 📊 **Analytics sorting enhancement.** The Analytics dashboard now supports sorting by Tokens column for both Model Usage and User Usage tables, and the Share/Percentage columns are now clickable for sorting. Administrators can more easily identify the most token-consuming models and users. [Commit](https://github.com/open-webui/open-webui/commit/053a33631f575ae1ad3123190a9e820b4057f62d) +- 📑 **Fetch URL citation sources.** When models fetch URLs during tool calling, the fetched URLs now appear as clickable citation sources in the UI with content previews, matching the existing behavior of web search and knowledge file tools. [#21669](https://github.com/open-webui/open-webui/pull/21669) +- 🔗 **Admin settings tab navigation.** The admin settings sidebar now supports native browser tab opening, allowing users to middle-click or right-click to open settings pages in new tabs. The navigation was converted from button-based to anchor-based elements. [#21721](https://github.com/open-webui/open-webui/pull/21721) +- 🏷️ **Model visibility badges.** The Admin Settings Models page now displays Public or Private badges directly on each model, making it easy to identify model access levels at a glance without opening the edit screen. [#21732](https://github.com/open-webui/open-webui/issues/21732), [Commit](https://github.com/open-webui/open-webui/commit/29217cb430bd47827ebb20782b264ae7b0f233bb) +- 🛠️ **Global model defaults.** Administrators can now configure default metadata and parameters that automatically apply to all models, reducing manual configuration for newly discovered models. Default capabilities (like vision, web search, code interpreter) and parameters (like temperature, max_tokens) can be set globally in Admin Settings, with per-model overrides still available. [#20658](https://github.com/open-webui/open-webui/issues/20658), [Commit](https://github.com/open-webui/open-webui/commit/c341f97cfe15510b7d128bd84f1e607b5289b957) +- 💬 **Plaintext tool output display.** Tool outputs that are plain strings now display naturally in a monospace block instead of quoted/escaped format, making multi-line string outputs easier to read. [#21553](https://github.com/open-webui/open-webui/issues/21553), [Commit](https://github.com/open-webui/open-webui/commit/3ad2ea6f2839e97e53f00fd797a9e083ff78d88e) +- 🔐 **Event call input masking.** Functions can now request masked password input in confirmation dialogs, allowing sensitive data entry to be hidden from view. This extends the existing masking feature from user valves to event calls. [#21540](https://github.com/open-webui/open-webui/issues/21540), [Commit](https://github.com/open-webui/open-webui/commit/4853ededcabcd76d9bd2036181486cd3a41458a1) +- 🗂️ **JSON logging support.** Administrators can now enable JSON-formatted logging by setting the LOG_FORMAT environment variable to "json", making logs suitable for log aggregators like Loki, Fluentd, CloudWatch, and Datadog. [#21747](https://github.com/open-webui/open-webui/pull/21747) +- ♿ **UI accessibility improvements.** Screen reader users can now navigate the interface more easily with improved keyboard navigation in dialogs and proper ARIA labels on all interactive elements. Added aria-labels to close, back, and action buttons across various components, and improved semantic HTML and screen reader support across auth, sidebar, chat, and notification components, addressing WCAG compliance. Added aria-labels to search inputs, select fields, and modals in admin and user settings, and improved accessibility for text inputs, rating components, citations, and web search results. Added aria-labels to workspace components including Knowledge, Models, Prompts, Skills, and Tools pages for improved screen reader support. [#21706](https://github.com/open-webui/open-webui/pull/21706), [#21705](https://github.com/open-webui/open-webui/pull/21705), [#21710](https://github.com/open-webui/open-webui/pull/21710), [#21709](https://github.com/open-webui/open-webui/pull/21709), [#21717](https://github.com/open-webui/open-webui/pull/21717), [#21715](https://github.com/open-webui/open-webui/pull/21715), [#21708](https://github.com/open-webui/open-webui/pull/21708), [#21719](https://github.com/open-webui/open-webui/pull/21719) +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 Translations for Finnish, French, Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded. + +### Fixed + +- 💥 **Admin functions page crash fix.** The admin Functions tab no longer crashes when clicked, fixing a null reference error that occurred while the functions list was loading. [#21661](https://github.com/open-webui/open-webui/pull/21661), [Commit](https://github.com/open-webui/open-webui/commit/8265422ba0660e7ba2192eb19efd70f8be652748) +- 💀 **Cyclic chat history deadlock fix.** Chat histories with circular parent-child message references no longer cause the backend to freeze when syncing usage stats. The system now detects and safely aborts when encountering cyclic message references. [#21681](https://github.com/open-webui/open-webui/pull/21681) +- 🔀 **Model fallback routing fix.** Custom model fallback now works correctly across all model types, preventing "Model not found" errors when the fallback model uses a different backend (pipe, Ollama, or OpenAI). [#21736](https://github.com/open-webui/open-webui/pull/21736) +- 🐛 **Default model selection fix.** Admin-configured default models are now properly respected when starting new chats instead of being overwritten by the first available model. [#21736](https://github.com/open-webui/open-webui/pull/21736) +- 👁️ **Scroll jumping fix.** Deleting a message pair after stopping generation no longer causes the chat to visually jump around, making message deletion smoother. [#21743](https://github.com/open-webui/open-webui/pull/21743), [Commit](https://github.com/open-webui/open-webui/commit/1f474187a77d2c8a392f00d86f48eb3cb3a18b88) +- 💬 **New chat message handling fix.** Fixed a bug where clicking "New Chat" after sending a message would silently drop subsequent messages. The system now properly clears pending message queues when starting a new conversation. [#21731](https://github.com/open-webui/open-webui/pull/21731) +- 🔍 **RAG template mutation fix.** Fixed a bug where RAG template text was recursively injected into user messages during multiple sequential tool calls, causing message content to grow exponentially and potentially confuse the model. The system now preserves the original user message before tool-calling loops and correctly accumulates citation sources. [#21663](https://github.com/open-webui/open-webui/issues/21663), [#21668](https://github.com/open-webui/open-webui/pull/21668), [Commit](https://github.com/open-webui/open-webui/commit/becac2b2b7af8aacadbfc9b7cee2024cf7ed6acc) +- 🔒 **Iframe sandbox security.** Embedded tools can no longer submit forms or access same-origin content by default, improving security for users. [#21529](https://github.com/open-webui/open-webui/pull/21529) +- 🔐 **Signup race condition fix.** Fixed a security vulnerability where multiple admin accounts could be created on fresh deployments when running multiple uvicorn workers. The signup handler now properly handles concurrent requests during first-user registration, preventing unauthorized admin privilege escalation. [#21631](https://github.com/open-webui/open-webui/pull/21631) +- 🔐 **LDAP optional fields fix.** LDAP configuration now properly accepts empty Application DN and password values, allowing LDAP authentication to work without these optional fields. Previously, empty values caused authentication failures. [Commit](https://github.com/open-webui/open-webui/commit/e1fa42d48a15c8b496a887ecfa32fc01cfd74b36) +- 🛠️ **API tools fix.** The /api/v1/chat/completions endpoint now properly respects caller-provided tools instead of overriding them with server-side tools, fixing issues where external agents like Claude Code or Cursor would receive unexpected tool advertisements. [#21557](https://github.com/open-webui/open-webui/issues/21557), [#21555](https://github.com/open-webui/open-webui/pull/21555) +- ⏱️ **Embeddings and proxy timeout fix.** The embeddings and OpenAI proxy endpoints now properly honor the AIOHTTP_CLIENT_TIMEOUT environment variable, instead of using default timeouts that could cause requests to hang. [#21558](https://github.com/open-webui/open-webui/pull/21558) +- 📄 **Text file type detection fix.** TypeScript and other text files that were mis-detected as video files based on their extension are now correctly identified and processed as text files, fixing upload rejections for .ts files. [#21454](https://github.com/open-webui/open-webui/issues/21454), [Commit](https://github.com/open-webui/open-webui/commit/f651809001ba8e40ba5f416773c1aa6f082a6c46) +- 🗄️ **File access control respect.** The files list and search endpoints now properly respect the BYPASS_ADMIN_ACCESS_CONTROL setting, ensuring admins only see their own files when the setting is disabled, consistent with other endpoints. [#21595](https://github.com/open-webui/open-webui/pull/21595), [#21589](https://github.com/open-webui/open-webui/issues/21589) +- 🗄️ **PostgreSQL workspace cloning.** Cloning workspace models now works correctly on PostgreSQL databases by generating proper unique IDs for access grants instead of using potentially duplicate or invalid IDs. [Commit](https://github.com/open-webui/open-webui/commit/3dd44c4f1931d13bfd46062291c6f23b33dde003) +- 🔓 **MCP SSL verification fix.** MCP tool connections now properly respect the AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL environment variable to disable SSL verification, instead of always verifying SSL certificates. [Commit](https://github.com/open-webui/open-webui/commit/af5661c2c807465f5600899e8c1a421f96cd7a8c), [#21481](https://github.com/open-webui/open-webui/issues/21481) +- 🔒 **Model default feature permissions.** Model default features like code interpreter, web search, and image generation now respect global configuration and user permission settings, preventing disabled features from appearing in the chat input. [#21690](https://github.com/open-webui/open-webui/pull/21690) +- 🔍 **Model selector typing fix.** The model selector list no longer disappears or becomes grayed out when typing quickly in the search field, thanks to improved virtual scroll handling. [#21659](https://github.com/open-webui/open-webui/pull/21659) +- ⛔ **Disabled model cloning prevention.** Disabled models can no longer be cloned as workspace models, preventing invalid empty configurations from being created. The Clone option is now hidden for inactive models. [#21724](https://github.com/open-webui/open-webui/pull/21724) +- 🔧 **SCIM parameter handling.** The SCIM Users and Groups endpoints now accept out-of-range startIndex and count values by clamping them to valid ranges instead of returning errors, in compliance with RFC 7644. [#21577](https://github.com/open-webui/open-webui/pull/21577) +- 🔍 **Hybrid search result fix.** Hybrid search now returns correct results after fixing a bug where query result unpacking order was mismatched, causing search results to appear empty. [#21562](https://github.com/open-webui/open-webui/pull/21562) +- 🛠️ **Imported items display.** Imported functions and tools now appear immediately in the list after import, without requiring a page reload. [#21593](https://github.com/open-webui/open-webui/issues/21593) +- 🔄 **WebSocket race condition fix.** Collaborative note saves no longer crash with errors when users disconnect before pending saves complete, preventing AttributeError exceptions and excessive logging. [#21601](https://github.com/open-webui/open-webui/issues/21601), [Commit](https://github.com/open-webui/open-webui/commit/0a700aafe46dfea2cf9721bb81725d2582b0d781) +- ✋ **Drag-and-drop overlay fix.** The "Add Files" overlay no longer remains stuck on screen when dragging files back out of the chat window in Mozilla Firefox. [#21664](https://github.com/open-webui/open-webui/pull/21664) +- 👁️ **Group search visibility fix.** Groups now appear correctly in access control search results, even when the search doesn't match any users. [#21691](https://github.com/open-webui/open-webui/pull/21691) +- 🖱️ **User menu drag and click fixes.** Fixed draggable ghost images when dragging menu items and eliminated phantom link clicks that occurred when dragging outside dropdown menus. [#21699](https://github.com/open-webui/open-webui/pull/21699) +- 🧭 **Admin and workspace nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Admin and Workspace panels by adding proper drag constraints and text selection prevention. [#21701](https://github.com/open-webui/open-webui/pull/21701) +- 🎮 **Playground nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Playground panel by adding proper drag constraints and text selection prevention. [#21704](https://github.com/open-webui/open-webui/pull/21704) +- ✋ **Dropdown menu drag fix.** Dropdown menu items can no longer be accidentally dragged as ghost images when highlighting text, making menu interactions smoother. [#21713](https://github.com/open-webui/open-webui/pull/21713) +- 🗂️ **Folder menu drag fix.** Folder dropdown menu items can no longer be accidentally highlighted or dragged as ghost images, making folder options behave like standard menus. [#21753](https://github.com/open-webui/open-webui/pull/21753) +- 📝 **Console log spam fix.** Requesting deleted or missing files no longer floods the backend console with Python traceback logs, thanks to proper exception handling for expected 404 errors. [#21687](https://github.com/open-webui/open-webui/pull/21687) +- 🐛 **Firefox avatar overflow fix.** Fixed a visual bug in Firefox where broken model or user avatar images would display overflowing alt text that overlapped adjacent labels on the Analytics and Leaderboard pages. Failed avatar images now properly show fallback icons instead. [#21730](https://github.com/open-webui/open-webui/pull/21730) +- 🎨 **Dark mode select background fix.** Fixed an issue where select inputs and dropdown menus had inconsistent lighter background colors in dark mode by removing conflicting dark theme overrides, ensuring a cohesive transparent look. [#21728](https://github.com/open-webui/open-webui/pull/21728) +- 💾 **Prompt import fix.** Importing prompts that were previously exported no longer fails with a "[object Object]" error toast, making prompt backup and restore work correctly. [#21594](https://github.com/open-webui/open-webui/issues/21594) +- 🔧 **Ollama reasoning effort fix.** Reasoning effort now works correctly with Ollama models that require string values ("low", "medium", "high") instead of boolean, fixing "invalid option provided" errors when using models like GPT-OSS. [#20921](https://github.com/open-webui/open-webui/issues/20921), [#20928](https://github.com/open-webui/open-webui/pull/20928), [Commit](https://github.com/open-webui/open-webui/commit/30a13b9b2fb2c6da7e1ddbf52edb93a58d09cc56) +- 🔍 **Hybrid search deduplication fix.** Hybrid search now correctly deduplicates results using content hashes, preventing duplicate chunks from appearing when using enriched text for BM25 search. [Commit](https://github.com/open-webui/open-webui/commit/d9fd2a3f30481efa24cc54193bf2f67fd0299b52) +- 📋 **SQLAlchemy warning fix.** Fixed a SQLAlchemy warning that appeared in logs when deleting shared chats, improving log clarity. [Commit](https://github.com/open-webui/open-webui/commit/0185f3340d2778f3b75a8036b0e81a0aec78037f) + +### Changed + +- 🎯 **Prompt suggestions relocated.** Prompt suggestions have been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - Models, where they can now be configured per-model or globally via the new model defaults. +- 📢 **Banners relocated.** Banners configuration has been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - General. + ## [0.8.3] - 2026-02-17 ### Added diff --git a/CHANGELOG_EXTRA.md b/CHANGELOG_EXTRA.md index 1b717c5876..17585bfbbe 100644 --- a/CHANGELOG_EXTRA.md +++ b/CHANGELOG_EXTRA.md @@ -5,6 +5,12 @@ 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.5.1] - 2026.02.24 + +### Changed + +- 合并官方 0.8.5 改动 + ## [0.8.3.1] - 2026.02.19 ### Changed diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 27b6e7090d..b942b80a24 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -548,6 +548,21 @@ def __getattr__(self, key): os.environ.get("ENABLE_OAUTH_GROUP_CREATION", "False").lower() == "true", ) + +oauth_group_default_share = ( + os.environ.get("OAUTH_GROUP_DEFAULT_SHARE", "true").strip().lower() +) +OAUTH_GROUP_DEFAULT_SHARE = PersistentConfig( + "OAUTH_GROUP_DEFAULT_SHARE", + "oauth.group_default_share", + ( + "members" + if oauth_group_default_share == "members" + else oauth_group_default_share == "true" + ), +) + + OAUTH_BLOCKED_GROUPS = PersistentConfig( "OAUTH_BLOCKED_GROUPS", "oauth.blocked_groups", @@ -1221,6 +1236,18 @@ def reachable(host: str, port: int) -> bool: [], ) +DEFAULT_MODEL_METADATA = PersistentConfig( + "DEFAULT_MODEL_METADATA", + "models.default_metadata", + {}, +) + +DEFAULT_MODEL_PARAMS = PersistentConfig( + "DEFAULT_MODEL_PARAMS", + "models.default_params", + {}, +) + DEFAULT_USER_ROLE = PersistentConfig( "DEFAULT_USER_ROLE", "ui.default_user_role", @@ -1394,6 +1421,10 @@ def reachable(host: str, port: int) -> bool: os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true" ) +USER_PERMISSIONS_CHAT_WEB_UPLOAD = ( + os.environ.get("USER_PERMISSIONS_CHAT_WEB_UPLOAD", "True").lower() == "true" +) + USER_PERMISSIONS_CHAT_DELETE = ( os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true" ) @@ -1529,6 +1560,7 @@ def reachable(host: str, port: int) -> bool: "system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT, "params": USER_PERMISSIONS_CHAT_PARAMS, "file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD, + "web_upload": USER_PERMISSIONS_CHAT_WEB_UPLOAD, "delete": USER_PERMISSIONS_CHAT_DELETE, "delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE, "continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE, @@ -1641,6 +1673,10 @@ def reachable(host: str, port: int) -> bool: os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true" ) +ENABLE_ADMIN_ANALYTICS = ( + os.environ.get("ENABLE_ADMIN_ANALYTICS", "True").lower() == "true" +) + ENABLE_COMMUNITY_SHARING = PersistentConfig( "ENABLE_COMMUNITY_SHARING", "ui.enable_community_sharing", @@ -2848,6 +2884,12 @@ class BannerModel(BaseModel): os.environ.get("ENABLE_ASYNC_EMBEDDING", "True").lower() == "true", ) +RAG_EMBEDDING_CONCURRENT_REQUESTS = PersistentConfig( + "RAG_EMBEDDING_CONCURRENT_REQUESTS", + "rag.embedding_concurrent_requests", + int(os.getenv("RAG_EMBEDDING_CONCURRENT_REQUESTS", "0")), +) + RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None) RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None) @@ -3416,6 +3458,12 @@ class BannerModel(BaseModel): os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""), ) +YOUCOM_API_KEY = PersistentConfig( + "YOUCOM_API_KEY", + "rag.web.search.youcom_api_key", + os.environ.get("YOUCOM_API_KEY", ""), +) + #################################### # Images #################################### diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 412a2b6116..1bf7a923df 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -5,6 +5,9 @@ import pkgutil import sys import shutil +import traceback +from datetime import datetime, timezone +from typing import Any from uuid import uuid4 from pathlib import Path from cryptography.hazmat.primitives import serialization @@ -72,9 +75,51 @@ # LOGGING #################################### +_LEVEL_MAP = { + "DEBUG": "debug", + "INFO": "info", + "WARNING": "warn", + "ERROR": "error", + "CRITICAL": "fatal", +} + + +class JSONFormatter(logging.Formatter): + """Format log records as single-line JSON objects for structured logging.""" + + def format(self, record: logging.LogRecord) -> str: + log_entry: dict[str, Any] = { + "ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat( + timespec="milliseconds" + ), + "level": _LEVEL_MAP.get(record.levelname, record.levelname.lower()), + "msg": record.getMessage(), + "caller": record.name, + } + + if record.exc_info and record.exc_info[0] is not None: + log_entry["error"] = "".join( + traceback.format_exception(*record.exc_info) + ).rstrip() + elif record.exc_text: + log_entry["error"] = record.exc_text + + if record.stack_info: + log_entry["stacktrace"] = record.stack_info + + return json.dumps(log_entry, ensure_ascii=False, default=str) + + +LOG_FORMAT = os.environ.get("LOG_FORMAT", "").lower() + GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping(): - logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) + if LOG_FORMAT == "json": + _handler = logging.StreamHandler(sys.stdout) + _handler.setFormatter(JSONFormatter()) + logging.basicConfig(handlers=[_handler], level=GLOBAL_LOG_LEVEL, force=True) + else: + logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) else: GLOBAL_LOG_LEVEL = "INFO" @@ -559,6 +604,10 @@ def parse_section(section): "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY ) +# Maximum number of concurrent OAuth sessions per user per provider +# This prevents unbounded session growth while allowing multi-device usage +OAUTH_MAX_SESSIONS_PER_USER = int(os.environ.get("OAUTH_MAX_SESSIONS_PER_USER", "10")) + # Token Exchange Configuration # Allows external apps to exchange OAuth tokens for OpenWebUI tokens ENABLE_OAUTH_TOKEN_EXCHANGE = ( @@ -984,6 +1033,11 @@ def parse_section(section): # TOOLS/FUNCTIONS PIP OPTIONS #################################### +ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = ( + os.environ.get("ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS", "True").lower() + == "true" +) + PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split() PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split() diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 7c2b100ae3..2f0dedd90c 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -230,6 +230,7 @@ RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_BATCH_SIZE, ENABLE_ASYNC_EMBEDDING, + RAG_EMBEDDING_CONCURRENT_REQUESTS, RAG_TOP_K, RAG_TOP_K_RERANKER, RAG_RELEVANCE_THRESHOLD, @@ -349,6 +350,7 @@ YANDEX_WEB_SEARCH_URL, YANDEX_WEB_SEARCH_API_KEY, YANDEX_WEB_SEARCH_CONFIG, + YOUCOM_API_KEY, # WebUI WEBUI_AUTH, WEBUI_NAME, @@ -382,6 +384,8 @@ DEFAULT_PINNED_MODELS, DEFAULT_ARENA_MODEL, MODEL_ORDER_LIST, + DEFAULT_MODEL_METADATA, + DEFAULT_MODEL_PARAMS, EVALUATION_ARENA_MODELS, # WebUI (OAuth) ENABLE_OAUTH_ROLE_MANAGEMENT, @@ -422,6 +426,7 @@ RESPONSE_WATERMARK, # Admin ENABLE_ADMIN_CHAT_ACCESS, + ENABLE_ADMIN_ANALYTICS, BYPASS_ADMIN_ACCESS_CONTROL, ENABLE_ADMIN_EXPORT, # Tasks @@ -522,6 +527,7 @@ WEBUI_ADMIN_PASSWORD, WEBUI_ADMIN_NAME, ENABLE_EASTER_EGGS, + LOG_FORMAT, ) from open_webui.utils.models import ( @@ -599,7 +605,8 @@ async def get_response(self, path: str, scope): raise ex -print(rf""" +if LOG_FORMAT != "json": + print(rf""" ██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║ @@ -847,6 +854,8 @@ async def lifespan(app: FastAPI): app.state.config.DEFAULT_MODELS = DEFAULT_MODELS app.state.config.DEFAULT_PINNED_MODELS = DEFAULT_PINNED_MODELS app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST +app.state.config.DEFAULT_MODEL_METADATA = DEFAULT_MODEL_METADATA +app.state.config.DEFAULT_MODEL_PARAMS = DEFAULT_MODEL_PARAMS app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS @@ -1007,6 +1016,7 @@ async def lifespan(app: FastAPI): app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE app.state.config.ENABLE_ASYNC_EMBEDDING = ENABLE_ASYNC_EMBEDDING +app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = RAG_EMBEDDING_CONCURRENT_REQUESTS app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL @@ -1091,6 +1101,7 @@ async def lifespan(app: FastAPI): app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG +app.state.config.YOUCOM_API_KEY = YOUCOM_API_KEY app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT @@ -1156,6 +1167,7 @@ async def lifespan(app: FastAPI): else None ), enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING, + concurrent_requests=app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, ) app.state.RERANKING_FUNCTION = get_reranking_function( @@ -1501,6 +1513,16 @@ async def check_url(request: Request, call_next): scheme="Bearer", credentials=request.cookies.get("token") ) + # Fallback to x-api-key header for Anthropic Messages API routes + if request.state.token is None and request.headers.get("x-api-key"): + request_path = request.url.path + if request_path in ("/api/message", "/api/v1/messages"): + from fastapi.security import HTTPAuthorizationCredentials + + request.state.token = HTTPAuthorizationCredentials( + scheme="Bearer", credentials=request.headers.get("x-api-key") + ) + request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS response = await call_next(request) process_time = int(time.time()) - start_time @@ -1572,7 +1594,8 @@ async def inspect_websocket(request: Request, call_next): app.include_router( evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"] ) -app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"]) +if ENABLE_ADMIN_ANALYTICS: + app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"]) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) # SCIM 2.0 API for identity management @@ -1743,9 +1766,18 @@ async def chat_completion( request.state.direct = True request.state.model = model - model_info_params = ( - model_info.params.model_dump() if model_info and model_info.params else {} + # Model params: global defaults as base, per-model overrides win + default_model_params = ( + getattr(request.app.state.config, "DEFAULT_MODEL_PARAMS", None) or {} ) + model_info_params = { + **default_model_params, + **( + model_info.params.model_dump() + if model_info and model_info.params + else {} + ), + } # Check base model existence for custom models if model_info_params.get("base_model_id"): @@ -1760,8 +1792,13 @@ async def chat_completion( default_models[0].strip() if default_models[0] else None ) - if fallback_model_id: - request.base_model_id = fallback_model_id + if ( + fallback_model_id + and fallback_model_id in request.app.state.MODELS + ): + # Update model and form_data so routing uses the fallback model's type + model = request.app.state.MODELS[fallback_model_id] + form_data["model"] = fallback_model_id else: raise Exception("Model not found") else: @@ -1817,9 +1854,12 @@ async def chat_completion( "local:" ): # temporary chats are not stored - # Verify chat ownership - chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id) - if chat is None and user.role != "admin": # admins can access any chat + # Verify chat ownership — lightweight EXISTS check avoids + # deserializing the full chat JSON blob just to confirm the row exists + if ( + not Chats.is_chat_owner(metadata["chat_id"], user.id) + and user.role != "admin" + ): # admins can access any chat raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.DEFAULT(), @@ -1969,6 +2009,68 @@ async def process_chat(request, form_data, user, metadata, model): generate_chat_completion = chat_completion +################################## +# +# Anthropic Messages API Compatible Endpoint +# +################################## + + +from open_webui.utils.anthropic import ( + convert_anthropic_to_openai_payload, + convert_openai_to_anthropic_response, + openai_stream_to_anthropic_stream, +) + + +@app.post("/api/message") +@app.post("/api/v1/messages") # Anthropic Messages API compatible endpoint +async def generate_messages( + request: Request, + form_data: dict, + user=Depends(get_verified_user), +): + """ + Anthropic Messages API compatible endpoint. + + Accepts the Anthropic Messages API format, converts internally to OpenAI + Chat Completions format, routes through the existing chat completion + pipeline, then converts the response back to Anthropic Messages format. + + Supports both streaming and non-streaming requests. + All models configured in Open WebUI are accessible via this endpoint. + + Authentication: Supports both standard Authorization header and + Anthropic's x-api-key header (via middleware translation). + """ + # Convert Anthropic payload to OpenAI format + requested_model = form_data.get("model", "") + + openai_payload = convert_anthropic_to_openai_payload(form_data) + + # Route through the existing chat_completion handler + response = await chat_completion(request, openai_payload, user) + + # Convert response back to Anthropic format + if isinstance(response, StreamingResponse): + # Streaming response: wrap the generator to convert SSE format + return StreamingResponse( + openai_stream_to_anthropic_stream( + response.body_iterator, model=requested_model + ), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + elif isinstance(response, dict): + return convert_openai_to_anthropic_response(response, model=requested_model) + else: + # Passthrough for error responses (JSONResponse, PlainTextResponse, etc.) + return response + + @app.post("/api/chat/completed") async def chat_completed( request: Request, form_data: dict, user=Depends(get_verified_user) @@ -2119,6 +2221,7 @@ async def get_app_config(request: Request): "enable_user_status": app.state.config.ENABLE_USER_STATUS, "enable_admin_export": ENABLE_ADMIN_EXPORT, "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, + "enable_admin_analytics": ENABLE_ADMIN_ANALYTICS, "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION, "enable_memories": app.state.config.ENABLE_MEMORIES, diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 7db9251282..720b90f5fc 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -1,8 +1,9 @@ +import logging from logging.config import fileConfig from alembic import context from open_webui.models.auths import Auth -from open_webui.env import DATABASE_URL, DATABASE_PASSWORD +from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT from sqlalchemy import engine_from_config, pool, create_engine # this is the Alembic Config object, which provides @@ -14,6 +15,13 @@ if config.config_file_name is not None: fileConfig(config.config_file_name, disable_existing_loggers=False) +# Re-apply JSON formatter after fileConfig replaces handlers. +if LOG_FORMAT == "json": + from open_webui.env import JSONFormatter + + for handler in logging.root.handlers: + handler.setFormatter(JSONFormatter()) + # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index dd5a344b46..227621becd 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -402,7 +402,7 @@ def set_access_grants( results = [] for grant_dict in normalized_grants: grant = AccessGrant( - id=grant_dict["id"], + id=str(uuid.uuid4()), resource_type=resource_type, resource_id=resource_id, principal_type=grant_dict["principal_type"], @@ -456,6 +456,31 @@ def get_grants_by_resource( ) return [AccessGrantModel.model_validate(g) for g in grants] + def get_grants_by_resources( + self, + resource_type: str, + resource_ids: list[str], + db: Optional[Session] = None, + ) -> dict[str, list[AccessGrantModel]]: + """Batch-fetch grants for multiple resources. Returns {resource_id: [grants]}.""" + if not resource_ids: + return {} + with get_db_context(db) as db: + grants = ( + db.query(AccessGrant) + .filter( + AccessGrant.resource_type == resource_type, + AccessGrant.resource_id.in_(resource_ids), + ) + .all() + ) + result: dict[str, list[AccessGrantModel]] = { + rid: [] for rid in resource_ids + } + for g in grants: + result[g.resource_id].append(AccessGrantModel.model_validate(g)) + return result + def has_access( self, user_id: str, diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 8a55da9345..e212789a44 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -261,13 +261,19 @@ def _get_access_grants( return AccessGrants.get_grants_by_resource("channel", channel_id, db=db) def _to_channel_model( - self, channel: Channel, db: Optional[Session] = None + self, + channel: Channel, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[Session] = None, ) -> ChannelModel: channel_data = ChannelModel.model_validate(channel).model_dump( exclude={"access_grants"} ) - access_grants = self._get_access_grants(channel_data["id"], db=db) - channel_data["access_grants"] = access_grants + channel_data["access_grants"] = ( + access_grants + if access_grants is not None + else self._get_access_grants(channel_data["id"], db=db) + ) return ChannelModel.model_validate(channel_data) def _collect_unique_user_ids( @@ -368,7 +374,18 @@ def insert_new_channel( def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]: with get_db_context(db) as db: channels = db.query(Channel).all() - return [self._to_channel_model(channel, db=db) for channel in channels] + channel_ids = [channel.id for channel in channels] + grants_map = AccessGrants.get_grants_by_resources( + "channel", channel_ids, db=db + ) + return [ + self._to_channel_model( + channel, + access_grants=grants_map.get(channel.id, []), + db=db, + ) + for channel in channels + ] def _has_permission(self, db, query, filter: dict, permission: str = "read"): return AccessGrants.has_permission_filter( @@ -417,7 +434,14 @@ def get_channels_by_user_id( standard_channels = query.all() all_channels = membership_channels + standard_channels - return [self._to_channel_model(c, db=db) for c in all_channels] + channel_ids = [c.id for c in all_channels] + grants_map = AccessGrants.get_grants_by_resources( + "channel", channel_ids, db=db + ) + return [ + self._to_channel_model(c, access_grants=grants_map.get(c.id, []), db=db) + for c in all_channels + ] def get_dm_channel_by_user_ids( self, user_ids: list[str], db: Optional[Session] = None @@ -724,7 +748,17 @@ def get_channels_by_file_id( ) channel_ids = [cf.channel_id for cf in channel_files] channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all() - return [self._to_channel_model(channel, db=db) for channel in channels] + grants_map = AccessGrants.get_grants_by_resources( + "channel", channel_ids, db=db + ) + return [ + self._to_channel_model( + channel, + access_grants=grants_map.get(channel.id, []), + db=db, + ) + for channel in channels + ] def get_channels_by_file_id_and_user_id( self, file_id: str, user_id: str, db: Optional[Session] = None diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 1418abd62d..8c6eb830b5 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -456,11 +456,11 @@ def update_chat_tags_by_id( return ChatModel.model_validate(chat) def get_chat_title_by_id(self, id: str) -> Optional[str]: - chat = self.get_chat_by_id(id) - if chat is None: - return None - - return chat.chat.get("title", "New Chat") + with get_db_context() as db: + result = db.query(Chat.title).filter_by(id=id).first() + if result is None: + return None + return result[0] or "New Chat" def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]: chat = self.get_chat_by_id(id) @@ -489,6 +489,7 @@ def upsert_message_to_chat_by_id_and_message_id( if isinstance(message.get("content"), str): message["content"] = sanitize_text_for_db(message["content"]) + user_id = chat.user_id chat = chat.chat history = chat.get("history", {}) @@ -509,7 +510,7 @@ def upsert_message_to_chat_by_id_and_message_id( ChatMessages.upsert_message( message_id=message_id, chat_id=id, - user_id=self.get_chat_by_id(id).user_id, + user_id=user_id, data=history["messages"][message_id], ) except Exception as e: @@ -630,7 +631,9 @@ def delete_shared_chat_by_chat_id( with get_db_context(db) as db: # Use subquery to delete chat_messages for shared chats shared_chat_id_subquery = ( - db.query(Chat.id).filter_by(user_id=f"shared-{chat_id}").subquery() + db.query(Chat.id) + .filter_by(user_id=f"shared-{chat_id}") + .scalar_subquery() ) db.query(ChatMessage).filter( ChatMessage.chat_id.in_(shared_chat_id_subquery) @@ -713,7 +716,7 @@ def get_archived_chat_list_by_user_id( skip: int = 0, limit: int = 50, db: Optional[Session] = None, - ) -> list[ChatModel]: + ) -> list[ChatTitleIdResponse]: with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id, archived=True) @@ -739,13 +742,27 @@ def get_archived_chat_list_by_user_id( else: query = query.order_by(Chat.updated_at.desc()) + query = query.with_entities( + Chat.id, Chat.title, Chat.updated_at, Chat.created_at + ) + if skip: query = query.offset(skip) if limit: query = query.limit(limit) all_chats = query.all() - return [ChatModel.model_validate(chat) for chat in all_chats] + return [ + ChatTitleIdResponse.model_validate( + { + "id": chat[0], + "title": chat[1], + "updated_at": chat[2], + "created_at": chat[3], + } + ) + for chat in all_chats + ] def get_shared_chat_list_by_user_id( self, @@ -754,7 +771,7 @@ def get_shared_chat_list_by_user_id( skip: int = 0, limit: int = 50, db: Optional[Session] = None, - ) -> list[ChatModel]: + ) -> list[SharedChatResponse]: with get_db_context(db) as db: query = ( @@ -784,13 +801,34 @@ def get_shared_chat_list_by_user_id( else: query = query.order_by(Chat.updated_at.desc()) + # Select only the columns needed for SharedChatResponse + # to avoid loading the heavy chat JSON blob + query = query.with_entities( + Chat.id, + Chat.title, + Chat.share_id, + Chat.updated_at, + Chat.created_at, + ) + if skip: query = query.offset(skip) if limit: query = query.limit(limit) all_chats = query.all() - return [ChatModel.model_validate(chat) for chat in all_chats] + return [ + SharedChatResponse.model_validate( + { + "id": chat[0], + "title": chat[1], + "share_id": chat[2], + "updated_at": chat[3], + "created_at": chat[4], + } + ) + for chat in all_chats + ] def get_chat_list_by_user_id( self, @@ -938,6 +976,37 @@ def get_chat_by_id_and_user_id( except Exception: return None + def is_chat_owner( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + """ + Lightweight ownership check — uses EXISTS subquery instead of loading + the full Chat row (which includes the potentially large JSON blob). + """ + try: + with get_db_context(db) as db: + return db.query( + exists().where(and_(Chat.id == id, Chat.user_id == user_id)) + ).scalar() + except Exception: + return False + + def get_chat_folder_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> Optional[str]: + """ + Fetch only the folder_id column for a chat, without loading the full + JSON blob. Returns None if chat doesn't exist or doesn't belong to user. + """ + try: + with get_db_context(db) as db: + result = ( + db.query(Chat.folder_id).filter_by(id=id, user_id=user_id).first() + ) + return result[0] if result else None + except Exception: + return None + def get_chats( self, skip: int = 0, limit: int = 50, db: Optional[Session] = None ) -> list[ChatModel]: @@ -997,14 +1066,25 @@ def get_chats_by_user_id( def get_pinned_chats_by_user_id( self, user_id: str, db: Optional[Session] = None - ) -> list[ChatModel]: + ) -> list[ChatTitleIdResponse]: with get_db_context(db) as db: all_chats = ( db.query(Chat) .filter_by(user_id=user_id, pinned=True, archived=False) .order_by(Chat.updated_at.desc()) + .with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at) ) - return [ChatModel.model_validate(chat) for chat in all_chats] + return [ + ChatTitleIdResponse.model_validate( + { + "id": chat[0], + "title": chat[1], + "updated_at": chat[2], + "created_at": chat[3], + } + ) + for chat in all_chats + ] def get_archived_chats_by_user_id( self, user_id: str, db: Optional[Session] = None diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 1d21d5d910..4e5e208d8b 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -144,13 +144,18 @@ def _get_access_grants( return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db) def _to_knowledge_model( - self, knowledge: Knowledge, db: Optional[Session] = None + self, + knowledge: Knowledge, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[Session] = None, ) -> KnowledgeModel: knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump( exclude={"access_grants"} ) - knowledge_data["access_grants"] = self._get_access_grants( - knowledge_data["id"], db=db + knowledge_data["access_grants"] = ( + access_grants + if access_grants is not None + else self._get_access_grants(knowledge_data["id"], db=db) ) return KnowledgeModel.model_validate(knowledge_data) @@ -192,9 +197,13 @@ def get_knowledge_bases( db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() ) user_ids = list(set(knowledge.user_id for knowledge in all_knowledge)) + knowledge_ids = [knowledge.id for knowledge in all_knowledge] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} + grants_map = AccessGrants.get_grants_by_resources( + "knowledge", knowledge_ids, db=db + ) knowledge_bases = [] for knowledge in all_knowledge: @@ -202,7 +211,11 @@ def get_knowledge_bases( knowledge_bases.append( KnowledgeUserModel.model_validate( { - **self._to_knowledge_model(knowledge, db=db).model_dump(), + **self._to_knowledge_model( + knowledge, + access_grants=grants_map.get(knowledge.id, []), + db=db, + ).model_dump(), "user": user.model_dump() if user else None, } ) @@ -261,13 +274,20 @@ def search_knowledge_bases( items = query.all() + knowledge_ids = [kb.id for kb, _ in items] + grants_map = AccessGrants.get_grants_by_resources( + "knowledge", knowledge_ids, db=db + ) + knowledge_bases = [] for knowledge_base, user in items: knowledge_bases.append( KnowledgeUserModel.model_validate( { **self._to_knowledge_model( - knowledge_base, db=db + knowledge_base, + access_grants=grants_map.get(knowledge_base.id, []), + db=db, ).model_dump(), "user": ( UserModel.model_validate(user).model_dump() @@ -440,8 +460,16 @@ def get_knowledges_by_file_id( .filter(KnowledgeFile.file_id == file_id) .all() ) + knowledge_ids = [k.id for k in knowledges] + grants_map = AccessGrants.get_grants_by_resources( + "knowledge", knowledge_ids, db=db + ) return [ - self._to_knowledge_model(knowledge, db=db) + self._to_knowledge_model( + knowledge, + access_grants=grants_map.get(knowledge.id, []), + db=db, + ) for knowledge in knowledges ] except Exception: diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 113e0d9fe7..166743345e 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -182,11 +182,20 @@ def _get_access_grants( ) -> list[AccessGrantModel]: return AccessGrants.get_grants_by_resource("model", model_id, db=db) - def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel: + def _to_model_model( + self, + model: Model, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[Session] = None, + ) -> ModelModel: model_data = ModelModel.model_validate(model).model_dump( exclude={"access_grants"} ) - model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db) + model_data["access_grants"] = ( + access_grants + if access_grants is not None + else self._get_access_grants(model_data["id"], db=db) + ) return ModelModel.model_validate(model_data) def insert_new_model( @@ -219,8 +228,14 @@ def insert_new_model( def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]: with get_db_context(db) as db: + all_models = db.query(Model).all() + model_ids = [model.id for model in all_models] + grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db) return [ - self._to_model_model(model, db=db) for model in db.query(Model).all() + self._to_model_model( + model, access_grants=grants_map.get(model.id, []), db=db + ) + for model in all_models ] def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: @@ -228,9 +243,11 @@ def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: all_models = db.query(Model).filter(Model.base_model_id != None).all() user_ids = list(set(model.user_id for model in all_models)) + model_ids = [model.id for model in all_models] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} + grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db) models = [] for model in all_models: @@ -238,7 +255,11 @@ def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: models.append( ModelUserResponse.model_validate( { - **self._to_model_model(model, db=db).model_dump(), + **self._to_model_model( + model, + access_grants=grants_map.get(model.id, []), + db=db, + ).model_dump(), "user": user.model_dump() if user else None, } ) @@ -247,9 +268,14 @@ def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]: with get_db_context(db) as db: + all_models = db.query(Model).filter(Model.base_model_id == None).all() + model_ids = [model.id for model in all_models] + grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db) return [ - self._to_model_model(model, db=db) - for model in db.query(Model).filter(Model.base_model_id == None).all() + self._to_model_model( + model, access_grants=grants_map.get(model.id, []), db=db + ) + for model in all_models ] def get_models_by_user_id( @@ -363,11 +389,18 @@ def search_models( items = query.all() + model_ids = [model.id for model, _ in items] + grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db) + models = [] for model, user in items: models.append( ModelUserResponse( - **self._to_model_model(model, db=db).model_dump(), + **self._to_model_model( + model, + access_grants=grants_map.get(model.id, []), + db=db, + ).model_dump(), user=( UserResponse(**UserModel.model_validate(user).model_dump()) if user @@ -394,7 +427,18 @@ def get_models_by_ids( try: with get_db_context(db) as db: models = db.query(Model).filter(Model.id.in_(ids)).all() - return [self._to_model_model(model, db=db) for model in models] + model_ids = [model.id for model in models] + grants_map = AccessGrants.get_grants_by_resources( + "model", model_ids, db=db + ) + return [ + self._to_model_model( + model, + access_grants=grants_map.get(model.id, []), + db=db, + ) + for model in models + ] except Exception: return [] @@ -503,9 +547,18 @@ def sync_models( db.commit() + all_models = db.query(Model).all() + model_ids = [model.id for model in all_models] + grants_map = AccessGrants.get_grants_by_resources( + "model", model_ids, db=db + ) return [ - self._to_model_model(model, db=db) - for model in db.query(Model).all() + self._to_model_model( + model, + access_grants=grants_map.get(model.id, []), + db=db, + ) + for model in all_models ] except Exception as e: log.exception(f"Error syncing models for user {user_id}: {e}") diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index d17c749d1c..ff8a3ac635 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -93,9 +93,18 @@ def _get_access_grants( ) -> list[AccessGrantModel]: return AccessGrants.get_grants_by_resource("note", note_id, db=db) - def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel: + def _to_note_model( + self, + note: Note, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[Session] = None, + ) -> NoteModel: note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"}) - note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db) + note_data["access_grants"] = ( + access_grants + if access_grants is not None + else self._get_access_grants(note_data["id"], db=db) + ) return NoteModel.model_validate(note_data) def _has_permission(self, db, query, filter: dict, permission: str = "read"): @@ -142,7 +151,14 @@ def get_notes( if limit is not None: query = query.limit(limit) notes = query.all() - return [self._to_note_model(note, db=db) for note in notes] + note_ids = [note.id for note in notes] + grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db) + return [ + self._to_note_model( + note, access_grants=grants_map.get(note.id, []), db=db + ) + for note in notes + ] def search_notes( self, @@ -227,11 +243,18 @@ def search_notes( items = query.all() + note_ids = [note.id for note, _ in items] + grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db) + notes = [] for note, user in items: notes.append( NoteUserResponse( - **self._to_note_model(note, db=db).model_dump(), + **self._to_note_model( + note, + access_grants=grants_map.get(note.id, []), + db=db, + ).model_dump(), user=( UserResponse(**UserModel.model_validate(user).model_dump()) if user @@ -266,7 +289,14 @@ def get_notes_by_user_id( query = query.limit(limit) notes = query.all() - return [self._to_note_model(note, db=db) for note in notes] + note_ids = [note.id for note in notes] + grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db) + return [ + self._to_note_model( + note, access_grants=grants_map.get(note.id, []), db=db + ) + for note in notes + ] def get_note_by_id( self, id: str, db: Optional[Session] = None diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index 538937483f..fbcd763f34 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -188,6 +188,7 @@ def get_session_by_provider_and_user_id( session = ( db.query(OAuthSession) .filter_by(provider=provider, user_id=user_id) + .order_by(OAuthSession.created_at.desc()) .first() ) if session: diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 3ab7a496ab..e32621f4e5 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -97,12 +97,19 @@ def _get_access_grants( return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db) def _to_prompt_model( - self, prompt: Prompt, db: Optional[Session] = None + self, + prompt: Prompt, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[Session] = None, ) -> PromptModel: prompt_data = PromptModel.model_validate(prompt).model_dump( exclude={"access_grants"} ) - prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db) + prompt_data["access_grants"] = ( + access_grants + if access_grants is not None + else self._get_access_grants(prompt_data["id"], db=db) + ) return PromptModel.model_validate(prompt_data) def insert_new_prompt( @@ -206,9 +213,13 @@ def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: ) user_ids = list(set(prompt.user_id for prompt in all_prompts)) + prompt_ids = [prompt.id for prompt in all_prompts] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} + grants_map = AccessGrants.get_grants_by_resources( + "prompt", prompt_ids, db=db + ) prompts = [] for prompt in all_prompts: @@ -216,7 +227,11 @@ def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: prompts.append( PromptUserResponse.model_validate( { - **self._to_prompt_model(prompt, db=db).model_dump(), + **self._to_prompt_model( + prompt, + access_grants=grants_map.get(prompt.id, []), + db=db, + ).model_dump(), "user": user.model_dump() if user else None, } ) @@ -259,7 +274,6 @@ def search_prompts( # Join with User table for user filtering and sorting query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id) - query = query.filter(Prompt.is_active == True) if filter: query_key = filter.get("query") @@ -330,11 +344,20 @@ def search_prompts( items = query.all() + prompt_ids = [prompt.id for prompt, _ in items] + grants_map = AccessGrants.get_grants_by_resources( + "prompt", prompt_ids, db=db + ) + prompts = [] for prompt, user in items: prompts.append( PromptUserResponse( - **self._to_prompt_model(prompt, db=db).model_dump(), + **self._to_prompt_model( + prompt, + access_grants=grants_map.get(prompt.id, []), + db=db, + ).model_dump(), user=( UserResponse(**UserModel.model_validate(user).model_dump()) if user @@ -562,55 +585,51 @@ def update_prompt_version( except Exception: return None - def delete_prompt_by_command( - self, command: str, db: Optional[Session] = None - ) -> bool: - """Soft delete a prompt by setting is_active to False.""" + def toggle_prompt_active( + self, prompt_id: str, db: Optional[Session] = None + ) -> Optional[PromptModel]: + """Toggle the is_active flag on a prompt.""" try: with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(command=command).first() + prompt = db.query(Prompt).filter_by(id=prompt_id).first() if prompt: - PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) - AccessGrants.revoke_all_access("prompt", prompt.id, db=db) - - prompt.is_active = False + prompt.is_active = not prompt.is_active prompt.updated_at = int(time.time()) db.commit() - return True - return False + db.refresh(prompt) + return self._to_prompt_model(prompt, db=db) + return None except Exception: - return False + return None - def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool: - """Soft delete a prompt by setting is_active to False.""" + def delete_prompt_by_command( + self, command: str, db: Optional[Session] = None + ) -> bool: + """Permanently delete a prompt and its history.""" try: with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(id=prompt_id).first() + prompt = db.query(Prompt).filter_by(command=command).first() if prompt: PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) AccessGrants.revoke_all_access("prompt", prompt.id, db=db) - prompt.is_active = False - prompt.updated_at = int(time.time()) + db.delete(prompt) db.commit() return True return False except Exception: return False - def hard_delete_prompt_by_command( - self, command: str, db: Optional[Session] = None - ) -> bool: + def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool: """Permanently delete a prompt and its history.""" try: with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(command=command).first() + prompt = db.query(Prompt).filter_by(id=prompt_id).first() if prompt: PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) AccessGrants.revoke_all_access("prompt", prompt.id, db=db) - # Delete prompt - db.query(Prompt).filter_by(command=command).delete() + db.delete(prompt) db.commit() return True return False diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 1262830153..6bd5affce8 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -110,11 +110,20 @@ def _get_access_grants( ) -> 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: + def _to_skill_model( + self, + skill: Skill, + access_grants: Optional[list[AccessGrantModel]] = None, + 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) + skill_data["access_grants"] = ( + access_grants + if access_grants is not None + else self._get_access_grants(skill_data["id"], db=db) + ) return SkillModel.model_validate(skill_data) def insert_new_skill( @@ -172,9 +181,11 @@ def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all() user_ids = list(set(skill.user_id for skill in all_skills)) + skill_ids = [skill.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} + grants_map = AccessGrants.get_grants_by_resources("skill", skill_ids, db=db) skills = [] for skill in all_skills: @@ -182,7 +193,11 @@ def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: skills.append( SkillUserModel.model_validate( { - **self._to_skill_model(skill, db=db).model_dump(), + **self._to_skill_model( + skill, + access_grants=grants_map.get(skill.id, []), + db=db, + ).model_dump(), "user": user.model_dump() if user else None, } ) @@ -267,11 +282,20 @@ def search_skills( items = query.all() + skill_ids = [skill.id for skill, _ in items] + grants_map = AccessGrants.get_grants_by_resources( + "skill", skill_ids, db=db + ) + skills = [] for skill, user in items: skills.append( SkillUserResponse( - **self._to_skill_model(skill, db=db).model_dump(), + **self._to_skill_model( + skill, + access_grants=grants_map.get(skill.id, []), + db=db, + ).model_dump(), user=( UserResponse( **UserModel.model_validate(user).model_dump() diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index eaac4c385d..f813ce21cd 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -2,7 +2,7 @@ import time from typing import Optional -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.users import Users, UserResponse from open_webui.models.groups import Groups @@ -100,9 +100,18 @@ def _get_access_grants( ) -> list[AccessGrantModel]: return AccessGrants.get_grants_by_resource("tool", tool_id, db=db) - def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel: + def _to_tool_model( + self, + tool: Tool, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[Session] = None, + ) -> ToolModel: tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"}) - tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db) + tool_data["access_grants"] = ( + access_grants + if access_grants is not None + else self._get_access_grants(tool_data["id"], db=db) + ) return ToolModel.model_validate(tool_data) def insert_new_tool( @@ -147,14 +156,21 @@ def get_tool_by_id( except Exception: return None - def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]: + def get_tools( + self, defer_content: bool = False, db: Optional[Session] = None + ) -> list[ToolUserModel]: with get_db_context(db) as db: - all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all() + query = db.query(Tool).order_by(Tool.updated_at.desc()) + if defer_content: + query = query.options(defer(Tool.content), defer(Tool.specs)) + all_tools = query.all() user_ids = list(set(tool.user_id for tool in all_tools)) + tool_ids = [tool.id for tool in all_tools] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} + grants_map = AccessGrants.get_grants_by_resources("tool", tool_ids, db=db) tools = [] for tool in all_tools: @@ -162,7 +178,11 @@ def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]: tools.append( ToolUserModel.model_validate( { - **self._to_tool_model(tool, db=db).model_dump(), + **self._to_tool_model( + tool, + access_grants=grants_map.get(tool.id, []), + db=db, + ).model_dump(), "user": user.model_dump() if user else None, } ) @@ -170,9 +190,13 @@ def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]: return tools def get_tools_by_user_id( - self, user_id: str, permission: str = "write", db: Optional[Session] = None + self, + user_id: str, + permission: str = "write", + defer_content: bool = False, + db: Optional[Session] = None, ) -> list[ToolUserModel]: - tools = self.get_tools(db=db) + tools = self.get_tools(defer_content=defer_content, db=db) user_group_ids = { group.id for group in Groups.get_groups_by_member_id(user_id, db=db) } diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 40988070bc..4528019fe9 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -87,6 +87,14 @@ def get_content_from_url(request, url: str) -> str: return content, docs +CHUNK_HASH_KEY = "_chunk_hash" + + +def _content_hash(text: str) -> str: + """SHA-256 hash of text, used as a stable chunk identifier for RRF dedup.""" + return hashlib.sha256(text.encode()).hexdigest() + + class VectorSearchRetriever(BaseRetriever): collection_name: Any embedding_function: Any @@ -125,9 +133,11 @@ async def _aget_relevant_documents( results = [] for idx in range(len(ids)): + metadata = metadatas[idx] + metadata[CHUNK_HASH_KEY] = _content_hash(documents[idx]) results.append( Document( - metadata=metadatas[idx], + metadata=metadata, page_content=documents[idx], ) ) @@ -239,15 +249,21 @@ async def query_doc_with_hybrid_search( log.debug(f"query_doc_with_hybrid_search:doc {collection_name}") + original_texts = collection_result.documents[0] + bm25_metadatas = [ + {**meta, CHUNK_HASH_KEY: _content_hash(original_texts[idx])} + for idx, meta in enumerate(collection_result.metadatas[0]) + ] + bm25_texts = ( get_enriched_texts(collection_result) if enable_enriched_texts - else collection_result.documents[0] + else original_texts ) bm25_retriever = BM25Retriever.from_texts( texts=bm25_texts, - metadatas=collection_result.metadatas[0], + metadatas=bm25_metadatas, ) bm25_retriever.k = k @@ -257,18 +273,24 @@ async def query_doc_with_hybrid_search( top_k=k, ) + # Use CHUNK_HASH_KEY for dedup so enriched BM25 texts don't defeat RRF if hybrid_bm25_weight <= 0: ensemble_retriever = EnsembleRetriever( - retrievers=[vector_search_retriever], weights=[1.0] + retrievers=[vector_search_retriever], + weights=[1.0], + id_key=CHUNK_HASH_KEY, ) elif hybrid_bm25_weight >= 1: ensemble_retriever = EnsembleRetriever( - retrievers=[bm25_retriever], weights=[1.0] + retrievers=[bm25_retriever], + weights=[1.0], + id_key=CHUNK_HASH_KEY, ) else: ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_search_retriever], weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight], + id_key=CHUNK_HASH_KEY, ) compressor = RerankCompressor( @@ -291,7 +313,7 @@ async def query_doc_with_hybrid_search( # retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker if k < k_reranker: sorted_items = sorted( - zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True + zip(distances, documents, metadatas), key=lambda x: x[0], reverse=True ) sorted_items = sorted_items[:k] @@ -736,6 +758,7 @@ def get_embedding_function( embedding_batch_size, azure_api_version=None, enable_async=True, + concurrent_requests=0, ) -> Awaitable: if embedding_engine == "": # Sentence transformers: CPU-bound sync operation @@ -777,11 +800,25 @@ async def async_embedding_function(query, prefix=None, user=None): log.debug( f"generate_multiple_async: Processing {len(batches)} batches in parallel" ) - # Execute all batches in parallel - tasks = [ - embedding_function(batch, prefix=prefix, user=user) - for batch in batches - ] + # Use semaphore to limit concurrent embedding API requests + # 0 = unlimited (no semaphore) + if concurrent_requests: + semaphore = asyncio.Semaphore(concurrent_requests) + + async def generate_batch_with_semaphore(batch): + async with semaphore: + return await embedding_function( + batch, prefix=prefix, user=user + ) + + tasks = [ + generate_batch_with_semaphore(batch) for batch in batches + ] + else: + tasks = [ + embedding_function(batch, prefix=prefix, user=user) + for batch in batches + ] batch_results = await asyncio.gather(*tasks) else: log.debug( diff --git a/backend/open_webui/retrieval/web/ydc.py b/backend/open_webui/retrieval/web/ydc.py new file mode 100644 index 0000000000..21d725a895 --- /dev/null +++ b/backend/open_webui/retrieval/web/ydc.py @@ -0,0 +1,73 @@ +import logging +from typing import Optional, List + +import requests +from open_webui.retrieval.web.main import SearchResult, get_filtered_results + +log = logging.getLogger(__name__) + + +def search_youcom( + api_key: str, + query: str, + count: int, + filter_list: Optional[List[str]] = None, + language: str = "EN", +) -> List[SearchResult]: + """Search using You.com's YDC Index API and return the results as a list of SearchResult objects. + + Args: + api_key (str): A You.com API key + query (str): The query to search for + count (int): Maximum number of results to return + filter_list (list[str], optional): Domain filter list + language (str): Language code for search results (default: "EN") + """ + url = "https://ydc-index.io/v1/search" + headers = { + "Accept": "application/json", + "X-API-KEY": api_key, + } + params = { + "query": query, + "count": count, + "language": language, + } + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + json_response = response.json() + results = json_response.get("results", {}).get("web", []) + + if filter_list: + results = get_filtered_results(results, filter_list) + + return [ + SearchResult( + link=result["url"], + title=result.get("title"), + snippet=_build_snippet(result), + ) + for result in results[:count] + ] + + +def _build_snippet(result: dict) -> str: + """Combine the description and snippets list into a single string. + + The You.com API returns a short ``description`` plus a ``snippets`` + list with richer passages. Merging them gives downstream retrieval + (embedding, BM25, bypass-loader context) the most content to work with. + """ + parts: list[str] = [] + + description = result.get("description") + if description: + parts.append(description) + + snippets = result.get("snippets") + if snippets and isinstance(snippets, list): + parts.extend(snippets) + + return "\n\n".join(parts) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index ab76b1b6ef..8bd97db1c5 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -719,6 +719,10 @@ async def signup_handler( Returns the newly created UserModel. Raises HTTPException on failure. """ + # Insert with default role first to avoid TOCTOU race on first signup. + # If has_users() is checked before insert, concurrent requests during + # first-user registration can all see an empty table and each get admin. + has_users = Users.has_users(db=db) if not has_users: role = "admin" @@ -727,6 +731,7 @@ async def signup_handler( send_verify_email(email=email.lower()) else: role = request.app.state.config.DEFAULT_USER_ROLE + hashed = get_password_hash(password) user = Auths.insert_new_auth( @@ -734,12 +739,19 @@ async def signup_handler( password=hashed, name=name, profile_image_url=profile_image_url, - role=role, + role=request.app.state.config.DEFAULT_USER_ROLE, db=db, ) if not user: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) + # Atomically check if this is the only user *after* the insert. + # Only the single user present at this point should become admin. + if Users.get_num_users(db=db) == 1: + Users.update_user_role_by_id(user.id, "admin", db=db) + user = Users.get_user_by_id(user.id, db=db) + request.app.state.config.ENABLE_SIGNUP = False + if request.app.state.config.WEBHOOK_URL: await post_webhook( request.app.state.WEBUI_NAME, @@ -752,10 +764,6 @@ async def signup_handler( }, ) - 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, @@ -1248,8 +1256,6 @@ async def update_ldap_server( "host", "attribute_for_mail", "attribute_for_username", - "app_dn", - "app_dn_password", "search_base", ] for key in required_fields: @@ -1264,8 +1270,8 @@ async def update_ldap_server( request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = ( form_data.attribute_for_username ) - request.app.state.config.LDAP_APP_DN = form_data.app_dn - request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password + request.app.state.config.LDAP_APP_DN = form_data.app_dn or "" + request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or "" request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters request.app.state.config.LDAP_USE_TLS = form_data.use_tls diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 69e47123f0..52e8e45c4d 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -723,10 +723,7 @@ async def get_chat_list_by_folder_id( async def get_user_pinned_chats( user=Depends(get_verified_user), db: Session = Depends(get_session) ): - return [ - ChatTitleIdResponse(**chat.model_dump()) - for chat in Chats.get_pinned_chats_by_user_id(user.id, db=db) - ] + return Chats.get_pinned_chats_by_user_id(user.id, db=db) ############################ @@ -821,18 +818,13 @@ async def get_archived_session_user_chat_list( if direction: filter["direction"] = direction - chat_list = [ - ChatTitleIdResponse(**chat.model_dump()) - for chat in Chats.get_archived_chat_list_by_user_id( - user.id, - filter=filter, - skip=skip, - limit=limit, - db=db, - ) - ] - - return chat_list + return Chats.get_archived_chat_list_by_user_id( + user.id, + filter=filter, + skip=skip, + limit=limit, + db=db, + ) ############################ @@ -887,18 +879,13 @@ async def get_shared_session_user_chat_list( if direction: filter["direction"] = direction - chat_list = [ - SharedChatResponse(**chat.model_dump()) - for chat in Chats.get_shared_chat_list_by_user_id( - user.id, - filter=filter, - skip=skip, - limit=limit, - db=db, - ) - ] - - return chat_list + return Chats.get_shared_chat_list_by_user_id( + user.id, + filter=filter, + skip=skip, + limit=limit, + db=db, + ) ############################ @@ -1147,7 +1134,7 @@ async def delete_chat_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chat = Chats.get_chat_by_id(id, db=db) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if not chat: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 4bee92c87b..a441c04066 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -466,6 +466,8 @@ class ModelsConfigForm(BaseModel): DEFAULT_MODELS: Optional[str] DEFAULT_PINNED_MODELS: Optional[str] MODEL_ORDER_LIST: Optional[list[str]] + DEFAULT_MODEL_METADATA: Optional[dict] = None + DEFAULT_MODEL_PARAMS: Optional[dict] = None @router.get("/models", response_model=ModelsConfigForm) @@ -474,6 +476,8 @@ async def get_models_config(request: Request, user=Depends(get_admin_user)): "DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS, "DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS, "MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST, + "DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA, + "DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS, } @@ -484,10 +488,14 @@ async def set_models_config( request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST + request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA + request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS return { "DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS, "DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS, "MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST, + "DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA, + "DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS, } diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 4a12db5cd9..68ef37afe4 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -47,6 +47,7 @@ from open_webui.storage.provider import Storage +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.misc import strict_match_mime_type from pydantic import BaseModel @@ -120,6 +121,27 @@ def has_access_to_file( ############################ +def _is_text_file(file_path: str, chunk_size: int = 8192) -> bool: + """Check if a file is likely a text file by reading a chunk and validating UTF-8. + + This catches files whose extensions are mis-mapped by mimetypes/browsers + (e.g. TypeScript .ts → video/mp2t) without maintaining an extension whitelist. + """ + try: + resolved = Storage.get_file(file_path) + with open(resolved, "rb") as f: + chunk = f.read(chunk_size) + if not chunk: + return False + # Null bytes are a strong indicator of binary content + if b"\x00" in chunk: + return False + chunk.decode("utf-8") + return True + except (UnicodeDecodeError, Exception): + return False + + def process_uploaded_file( request, file, @@ -131,14 +153,19 @@ def process_uploaded_file( ): def _process_handler(db_session): try: - if file.content_type: + content_type = file.content_type + + # Detect mis-labeled text files (e.g. .ts → video/mp2t) + if content_type and content_type.startswith(("image/", "video/")): + if _is_text_file(file_path): + content_type = "text/plain" + + if content_type: stt_supported_content_types = getattr( request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] ) - if strict_match_mime_type( - stt_supported_content_types, file.content_type - ): + if strict_match_mime_type(stt_supported_content_types, content_type): file_path_processed = Storage.get_file(file_path) result = transcribe( request, file_path_processed, file_metadata, user @@ -152,7 +179,7 @@ def _process_handler(db_session): user=user, db=db_session, ) - elif (not file.content_type.startswith(("image/", "video/"))) or ( + elif (not content_type.startswith(("image/", "video/"))) or ( request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external" ): process_file( @@ -163,7 +190,7 @@ def _process_handler(db_session): ) else: raise Exception( - f"File type {file.content_type} is not supported for processing" + f"File type {content_type} is not supported for processing" ) else: log.info( @@ -362,7 +389,7 @@ async def list_files( content: bool = Query(True), db: Session = Depends(get_session), ): - if user.role == "admin": + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: files = Files.get_files(db=db) else: files = Files.get_files_by_user_id(user.id, db=db) @@ -398,8 +425,10 @@ async def search_files( Search for files by filename with support for wildcard patterns. Uses SQL-based filtering with pagination for better performance. """ - # Determine user_id: null for admin (search all), user.id for regular users - user_id = None if user.role == "admin" else user.id + # Determine user_id: null for admin with bypass (search all), user.id otherwise + user_id = ( + None if (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) else user.id + ) # Use optimized database query with pagination files = Files.search_files( @@ -689,6 +718,8 @@ async def get_file_content_by_id( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + except HTTPException as e: + raise e except Exception as e: log.exception(e) log.error("Error getting file content") @@ -740,6 +771,8 @@ async def get_html_file_content_by_id( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + except HTTPException as e: + raise e except Exception as e: log.exception(e) log.error("Error getting file content") diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index d3bc3f8eee..48209fc05c 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -641,7 +641,10 @@ async def image_generations( for image in res["data"]: if image_url := image.get("url", None): - image_data, content_type = get_image_data(image_url, headers) + image_data, content_type = get_image_data( + image_url, + {k: v for k, v in headers.items() if k != "Content-Type"}, + ) else: image_data, content_type = get_image_data(image["b64_json"]) @@ -993,7 +996,10 @@ def get_image_file_item(base64_string, param_name="image"): images = [] for image in res["data"]: if image_url := image.get("url", None): - image_data, content_type = get_image_data(image_url, headers) + image_data, content_type = get_image_data( + image_url, + {k: v for k, v in headers.items() if k != "Content-Type"}, + ) else: image_data, content_type = get_image_data(image["b64_json"]) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index e93d8a729d..c417453486 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -512,6 +512,7 @@ async def update_model_by_id( class ModelAccessGrantsForm(BaseModel): id: str + name: Optional[str] = None access_grants: list[dict] @@ -535,7 +536,7 @@ async def update_model_access_by_id( model = Models.insert_new_model( ModelForm( id=form_data.id, - name=form_data.id, + name=form_data.name or form_data.id, meta=ModelMeta(), params=ModelParams(), ), diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 8d1a66c4af..41bb65f55a 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -36,6 +36,14 @@ router = APIRouter() + +def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optional[dict]: + if not data: + return data + md = (data.get("content") or {}).get("md") or "" + return {"content": {"md": md[:max_length]}} + + ############################ # GetNotes ############################ @@ -82,6 +90,7 @@ async def get_notes( NoteUserResponse( **{ **note.model_dump(), + "data": _truncate_note_data(note.data), "user": UserResponse(**users[note.user_id].model_dump()), } ) @@ -135,7 +144,10 @@ async def search_notes( filter["user_id"] = user.id - return Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db) + result = Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db) + for note in result.items: + note.data = _truncate_note_data(note.data) + return result ############################ diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 18440f1e8f..ee14ba9683 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -59,6 +59,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.credit.usage import CreditDeduct from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models log = logging.getLogger(__name__) @@ -93,6 +94,12 @@ async def send_get_request(url, key=None, user: UserModel = None): return None +async def get_models_request(url, key=None, user: UserModel = None): + if is_anthropic_url(url): + return await get_anthropic_models(url, key, user=user) + return await send_get_request(f"{url}/models", key, user=user) + + def openai_reasoning_model_handler(payload): """ Handle reasoning model specific parameters @@ -367,13 +374,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: request_tasks = [] 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", - api_keys[idx], - user=user, - ) - ) + request_tasks.append(get_models_request(url, api_keys[idx], user=user)) else: api_config = api_configs.get( str(idx), @@ -386,11 +387,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: if enable: if len(model_ids) == 0: request_tasks.append( - send_get_request( - f"{url}/models", - api_keys[idx], - user=user, - ) + get_models_request(url, api_keys[idx], user=user) ) else: model_list = { @@ -596,6 +593,10 @@ async def get_models( "data": api_config.get("model_ids", []) or [], "object": "list", } + elif is_anthropic_url(url): + models = await get_anthropic_models(url, key, user=user) + if models is None: + raise Exception("Failed to connect to Anthropic API") else: async with session.get( f"{url}/models", @@ -604,7 +605,6 @@ async def get_models( ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: - # Extract response error details if available error_detail = f"HTTP Error: {r.status}" try: res = await r.json() @@ -616,9 +616,7 @@ async def get_models( response_data = await r.json() - # Check if we're calling OpenAI API based on the URL if "api.openai.com" in url: - # Filter models according to the specified conditions response_data["data"] = [ model for model in response_data.get("data", []) @@ -709,6 +707,15 @@ async def verify_connection( ) return response_data + elif is_anthropic_url(url): + result = await get_anthropic_models(url, key) + if result is None: + raise HTTPException( + status_code=500, detail="Failed to connect to Anthropic API" + ) + if "error" in result: + raise HTTPException(status_code=500, detail=result["error"]) + return result else: async with session.get( f"{url}/models", @@ -1208,7 +1215,10 @@ async def embeddings(request: Request, form_data: dict, user): request, url, key, api_config, user=user ) try: - session = aiohttp.ClientSession(trust_env=True) + session = aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) r = await session.request( method="POST", url=f"{url}/embeddings", diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 2491578959..9653571fbb 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -497,6 +497,48 @@ async def update_prompt_access_by_id( return Prompts.get_prompt_by_id(prompt_id, db=db) +############################ +# TogglePromptActiveById +############################ + + +@router.post("/id/{prompt_id}/toggle", response_model=Optional[PromptModel]) +async def toggle_prompt_active( + prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + + if not prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + prompt.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="prompt", + resource_id=prompt.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + result = Prompts.toggle_prompt_active(prompt.id, db=db) + if result: + return result + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(), + ) + + ############################ # DeletePromptById ############################ diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 0d513c9ae1..1ff4cef3e6 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -77,6 +77,7 @@ from open_webui.retrieval.web.firecrawl import search_firecrawl from open_webui.retrieval.web.external import search_external from open_webui.retrieval.web.yandex import search_yandex +from open_webui.retrieval.web.ydc import search_youcom from open_webui.retrieval.utils import ( get_content_from_url, @@ -270,6 +271,7 @@ async def get_status(request: Request): "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, + "RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, } @@ -281,6 +283,7 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)): "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL, "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, + "RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, "openai_config": { "url": request.app.state.config.RAG_OPENAI_API_BASE_URL, "key": request.app.state.config.RAG_OPENAI_API_KEY, @@ -321,6 +324,7 @@ class EmbeddingModelUpdateForm(BaseModel): RAG_EMBEDDING_MODEL: str RAG_EMBEDDING_BATCH_SIZE: Optional[int] = 1 ENABLE_ASYNC_EMBEDDING: Optional[bool] = True + RAG_EMBEDDING_CONCURRENT_REQUESTS: Optional[int] = 0 def unload_embedding_model(request: Request): @@ -355,6 +359,9 @@ async def update_embedding_config( request.app.state.config.ENABLE_ASYNC_EMBEDDING = ( form_data.ENABLE_ASYNC_EMBEDDING ) + request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = ( + form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS + ) if request.app.state.config.RAG_EMBEDDING_ENGINE in [ "ollama", @@ -422,6 +429,7 @@ async def update_embedding_config( else None ), enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING, + concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, ) return { @@ -430,6 +438,7 @@ async def update_embedding_config( "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL, "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, + "RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, "openai_config": { "url": request.app.state.config.RAG_OPENAI_API_BASE_URL, "key": request.app.state.config.RAG_OPENAI_API_KEY, @@ -583,6 +592,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL, "YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, "YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, + "YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY, }, } @@ -649,6 +659,7 @@ class WebConfig(BaseModel): YANDEX_WEB_SEARCH_URL: Optional[str] = None YANDEX_WEB_SEARCH_API_KEY: Optional[str] = None YANDEX_WEB_SEARCH_CONFIG: Optional[str] = None + YOUCOM_API_KEY: Optional[str] = None class ConfigForm(BaseModel): @@ -1205,6 +1216,7 @@ async def update_rag_config( request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = ( form_data.web.YANDEX_WEB_SEARCH_CONFIG ) + request.app.state.config.YOUCOM_API_KEY = form_data.web.YOUCOM_API_KEY return { "status": True, @@ -1332,6 +1344,7 @@ async def update_rag_config( "YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL, "YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, "YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, + "YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY, }, } @@ -1589,6 +1602,7 @@ def _get_docs_info(docs: list[Document]) -> str: else None ), enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING, + concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, ) # Run async embedding in sync context using the main event loop @@ -1746,6 +1760,7 @@ def process_file( DOCLING_API_KEY=request.app.state.config.DOCLING_API_KEY, DOCLING_PARAMS=request.app.state.config.DOCLING_PARAMS, PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES, + PDF_LOADER_MODE=request.app.state.config.PDF_LOADER_MODE, DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, DOCUMENT_INTELLIGENCE_MODEL=request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, @@ -1933,6 +1948,9 @@ async def process_web( request: Request, form_data: ProcessUrlForm, process: bool = Query(True, description="Whether to process and save the content"), + overwrite: bool = Query( + True, description="Whether to overwrite existing collection" + ), user=Depends(get_verified_user), ): try: @@ -1952,7 +1970,7 @@ async def process_web( request, docs, collection_name, - overwrite=True, + overwrite=overwrite, user=user, ) else: @@ -2288,6 +2306,13 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, user=user, ) + elif engine == "youcom": + return search_youcom( + request.app.state.config.YOUCOM_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) else: raise Exception("No search engine API key found in environment variables") @@ -2327,7 +2352,7 @@ async def process_web_search( # Limited concurrency with semaphore semaphore = asyncio.Semaphore(concurrent_limit) - async def search_with_limit(query): + async def search_query_with_semaphore(query): async with semaphore: return await run_in_threadpool( search_web, @@ -2337,7 +2362,9 @@ async def search_with_limit(query): user, ) - search_tasks = [search_with_limit(query) for query in form_data.queries] + search_tasks = [ + search_query_with_semaphore(query) for query in form_data.queries + ] else: # Unlimited parallel execution (previous behavior) search_tasks = [ diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index 0c16eb99bd..4d56a7e97f 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -523,13 +523,17 @@ async def get_schemas(): @router.get("/Users", response_model=SCIMListResponse) async def get_users( request: Request, - startIndex: int = Query(1, ge=1), - count: int = Query(20, ge=1, le=100), + startIndex: int = Query(1), + count: int = Query(20), filter: Optional[str] = None, _: bool = Depends(get_scim_auth), db: Session = Depends(get_session), ): """List SCIM Users""" + # Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4): + # startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0. + startIndex = max(1, startIndex) + count = max(0, min(100, count)) skip = startIndex - 1 limit = count @@ -794,13 +798,18 @@ async def delete_user( @router.get("/Groups", response_model=SCIMListResponse) async def get_groups( request: Request, - startIndex: int = Query(1, ge=1), - count: int = Query(20, ge=1, le=100), + startIndex: int = Query(1), + count: int = Query(20), filter: Optional[str] = None, _: bool = Depends(get_scim_auth), db: Session = Depends(get_session), ): """List SCIM Groups""" + # Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4): + # startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0. + startIndex = max(1, startIndex) + count = max(0, min(100, count)) + # Get all groups groups_list = Groups.get_all_groups(db=db) diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index fab5039909..531cfe16a7 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -64,13 +64,19 @@ async def get_tools( tools = [] # Local Tools - for tool in Tools.get_tools(db=db): - tool_module = get_tool_module(request, tool.id) + for tool in Tools.get_tools(defer_content=True, db=db): + tool_module = ( + request.app.state.TOOLS.get(tool.id) + if hasattr(request.app.state, "TOOLS") + else None + ) tools.append( ToolUserResponse( **{ **tool.model_dump(), - "has_user_valves": hasattr(tool_module, "UserValves"), + "has_user_valves": ( + hasattr(tool_module, "UserValves") if tool_module else False + ), } ) ) @@ -196,27 +202,40 @@ async def get_tool_list( user=Depends(get_verified_user), db: Session = Depends(get_session) ): if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - tools = Tools.get_tools(db=db) + tools = Tools.get_tools(defer_content=True, db=db) else: - tools = Tools.get_tools_by_user_id(user.id, "read", db=db) - - return [ - ToolAccessResponse( - **tool.model_dump(), - write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) - or user.id == tool.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type="tool", - resource_id=tool.id, - permission="write", - db=db, + tools = Tools.get_tools_by_user_id(user.id, "read", defer_content=True, db=db) + + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } + + result = [] + for tool in tools: + has_write = ( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == tool.user_id + or any( + g.permission == "write" + and ( + ( + g.principal_type == "user" + and (g.principal_id == user.id or g.principal_id == "*") + ) + or ( + g.principal_type == "group" and g.principal_id in user_group_ids + ) ) - ), + for g in tool.access_grants + ) ) - for tool in tools - ] + result.append( + ToolAccessResponse( + **tool.model_dump(), + write_access=has_write, + ) + ) + return result ############################ diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index fc446602bd..aa33d78e99 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -229,6 +229,7 @@ class ChatPermissions(BaseModel): system_prompt: bool = True params: bool = True file_upload: bool = True + web_upload: bool = True delete: bool = True delete_message: bool = True continue_response: bool = True diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 78df66b8dc..c1d7e5c29e 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -511,6 +511,8 @@ async def channel_events(sid, data): async def ydoc_document_join(sid, data): """Handle user joining a document""" user = SESSION_POOL.get(sid) + if not user: + return try: document_id = data["document_id"] @@ -683,11 +685,13 @@ async def yjs_document_update(sid, data): skip_sid=sid, ) + user = SESSION_POOL.get(sid) + if not user: + return + async def debounced_save(): await asyncio.sleep(0.5) - await document_save_handler( - document_id, data.get("data", {}), SESSION_POOL.get(sid) - ) + await document_save_handler(document_id, data.get("data", {}), user) if data.get("data"): await create_task(REDIS, debounced_save(), document_id) diff --git a/backend/open_webui/test/apps/webui/routers/test_prompts.py b/backend/open_webui/test/apps/webui/routers/test_prompts.py deleted file mode 100644 index d91bf77dc5..0000000000 --- a/backend/open_webui/test/apps/webui/routers/test_prompts.py +++ /dev/null @@ -1,91 +0,0 @@ -from test.util.abstract_integration_test import AbstractPostgresTest -from test.util.mock_user import mock_webui_user - - -class TestPrompts(AbstractPostgresTest): - BASE_PATH = "/api/v1/prompts" - - def test_prompts(self): - # Get all prompts - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/")) - assert response.status_code == 200 - assert len(response.json()) == 0 - - # Create a two new prompts - with mock_webui_user(id="2"): - response = self.fast_api_client.post( - self.create_url("/create"), - json={ - "command": "/my-command", - "title": "Hello World", - "content": "description", - }, - ) - assert response.status_code == 200 - with mock_webui_user(id="3"): - response = self.fast_api_client.post( - self.create_url("/create"), - json={ - "command": "/my-command2", - "title": "Hello World 2", - "content": "description 2", - }, - ) - assert response.status_code == 200 - - # Get all prompts - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/")) - assert response.status_code == 200 - assert len(response.json()) == 2 - - # Get prompt by command - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/command/my-command")) - assert response.status_code == 200 - data = response.json() - assert data["command"] == "/my-command" - assert data["title"] == "Hello World" - assert data["content"] == "description" - assert data["user_id"] == "2" - - # Update prompt - with mock_webui_user(id="2"): - response = self.fast_api_client.post( - self.create_url("/command/my-command2/update"), - json={ - "command": "irrelevant for request", - "title": "Hello World Updated", - "content": "description Updated", - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["command"] == "/my-command2" - assert data["title"] == "Hello World Updated" - assert data["content"] == "description Updated" - assert data["user_id"] == "3" - - # Get prompt by command - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/command/my-command2")) - assert response.status_code == 200 - data = response.json() - assert data["command"] == "/my-command2" - assert data["title"] == "Hello World Updated" - assert data["content"] == "description Updated" - assert data["user_id"] == "3" - - # Delete prompt - with mock_webui_user(id="2"): - response = self.fast_api_client.delete( - self.create_url("/command/my-command/delete") - ) - assert response.status_code == 200 - - # Get all prompts - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/")) - assert response.status_code == 200 - assert len(response.json()) == 1 diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 31c79484ab..330baf1318 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -36,6 +36,8 @@ from open_webui.models.channels import Channels, ChannelMember, Channel from open_webui.models.messages import Messages, Message from open_webui.models.groups import Groups +from open_webui.models.memories import Memories +from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.utils.sanitize import sanitize_code log = logging.getLogger(__name__) @@ -634,6 +636,79 @@ async def replace_memory_content( return json.dumps({"error": str(e)}) +async def delete_memory( + memory_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Delete a memory by its ID. + + :param memory_id: The ID of the memory to delete + :return: Confirmation that the memory was deleted + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + user = UserModel(**__user__) if __user__ else None + + result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id) + + if result: + VECTOR_DB_CLIENT.delete( + collection_name=f"user-memory-{user.id}", ids=[memory_id] + ) + return json.dumps( + {"status": "success", "message": f"Memory {memory_id} deleted"}, + ensure_ascii=False, + ) + else: + return json.dumps({"error": "Memory not found or access denied"}) + except Exception as e: + log.exception(f"delete_memory error: {e}") + return json.dumps({"error": str(e)}) + + +async def list_memories( + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + List all stored memories for the user. + + :return: JSON list of all memories with id, content, and dates + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + user = UserModel(**__user__) if __user__ else None + + memories = Memories.get_memories_by_user_id(user.id) + + if memories: + result = [ + { + "id": m.id, + "content": m.content, + "created_at": time.strftime( + "%Y-%m-%d %H:%M", time.localtime(m.created_at) + ), + "updated_at": time.strftime( + "%Y-%m-%d %H:%M", time.localtime(m.updated_at) + ), + } + for m in memories + ] + return json.dumps(result, ensure_ascii=False) + else: + return json.dumps([]) + except Exception as e: + log.exception(f"list_memories error: {e}") + return json.dumps({"error": str(e)}) + + # ============================================================================= # NOTES TOOLS # ============================================================================= diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py new file mode 100644 index 0000000000..736f7238bc --- /dev/null +++ b/backend/open_webui/utils/anthropic.py @@ -0,0 +1,534 @@ +import json +import logging + +import aiohttp + +from open_webui.env import ( + AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, + ENABLE_FORWARD_USER_INFO_HEADERS, +) +from open_webui.models.users import UserModel +from open_webui.utils.headers import include_user_info_headers + +log = logging.getLogger(__name__) + + +def is_anthropic_url(url: str) -> bool: + """Check if the URL is an Anthropic API endpoint.""" + return "api.anthropic.com" in url + + +async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> dict: + """ + Fetch models from Anthropic's /v1/models endpoint with pagination. + Normalizes the response to OpenAI format. + """ + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + all_models = [] + after_id = None + + try: + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + headers = { + "x-api-key": key, + "anthropic-version": "2023-06-01", + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + while True: + params = {"limit": 1000} + if after_id: + params["after_id"] = after_id + + async with session.get( + f"{url}/models", + headers=headers, + params=params, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + if response.status != 200: + error_detail = f"HTTP Error: {response.status}" + try: + res = await response.json() + if "error" in res: + error_detail = f"External Error: {res['error']}" + except Exception: + pass + return {"object": "list", "data": [], "error": error_detail} + + data = await response.json() + + for model in data.get("data", []): + all_models.append( + { + "id": model.get("id"), + "object": "model", + "created": 0, + "owned_by": "anthropic", + "name": model.get("display_name", model.get("id")), + } + ) + + if not data.get("has_more", False): + break + after_id = data.get("last_id") + + except Exception as e: + log.error(f"Anthropic connection error: {e}") + return None + + return {"object": "list", "data": all_models} + + +############################## +# +# Anthropic Messages API Conversion Utilities +# +############################## + + +def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: + """ + Convert an Anthropic Messages API request to OpenAI Chat Completions format. + + Anthropic format: + {model, messages: [{role, content}], system, max_tokens, ...} + OpenAI format: + {model, messages: [{role, content}], max_tokens, ...} + """ + openai_payload = {} + + # Model + openai_payload["model"] = anthropic_payload.get("model", "") + + # Build messages list + messages = [] + + # System prompt (Anthropic has it as top-level, OpenAI as a system message) + system = anthropic_payload.get("system") + if system: + if isinstance(system, str): + messages.append({"role": "system", "content": system}) + elif isinstance(system, list): + # Anthropic supports system as list of content blocks + text_parts = [] + for block in system: + if isinstance(block, dict) and block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif isinstance(block, str): + text_parts.append(block) + messages.append({"role": "system", "content": "\n".join(text_parts)}) + + # Convert messages + for msg in anthropic_payload.get("messages", []): + role = msg.get("role", "user") + content = msg.get("content") + + if isinstance(content, str): + messages.append({"role": role, "content": content}) + elif isinstance(content, list): + # Convert Anthropic content blocks to OpenAI format + openai_content = [] + tool_calls = [] + + for block in content: + block_type = block.get("type", "text") + + if block_type == "text": + openai_content.append( + { + "type": "text", + "text": block.get("text", ""), + } + ) + elif block_type == "image": + source = block.get("source", {}) + if source.get("type") == "base64": + media_type = source.get("media_type", "image/png") + data = source.get("data", "") + openai_content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:{media_type};base64,{data}", + }, + } + ) + elif source.get("type") == "url": + openai_content.append( + { + "type": "image_url", + "image_url": {"url": source.get("url", "")}, + } + ) + elif block_type == "tool_use": + tool_calls.append( + { + "id": block.get("id", ""), + "type": "function", + "function": { + "name": block.get("name", ""), + "arguments": ( + json.dumps(block.get("input", {})) + if isinstance(block.get("input"), dict) + else str(block.get("input", "{}")) + ), + }, + } + ) + elif block_type == "tool_result": + # Tool results become separate tool messages in OpenAI format + tool_content = block.get("content", "") + if isinstance(tool_content, list): + tool_text_parts = [] + for tc in tool_content: + if isinstance(tc, dict) and tc.get("type") == "text": + tool_text_parts.append(tc.get("text", "")) + tool_content = "\n".join(tool_text_parts) + + # Propagate error status if present + if block.get("is_error"): + tool_content = f"Error: {tool_content}" + + messages.append( + { + "role": "tool", + "tool_call_id": block.get("tool_use_id", ""), + "content": tool_content, + } + ) + + # Build the message + if tool_calls: + # Assistant message with tool calls + msg_dict = {"role": role} + if openai_content: + # If there's only text, flatten it + if len(openai_content) == 1 and openai_content[0]["type"] == "text": + msg_dict["content"] = openai_content[0]["text"] + else: + msg_dict["content"] = openai_content + else: + msg_dict["content"] = "" + msg_dict["tool_calls"] = tool_calls + messages.append(msg_dict) + elif openai_content: + # If there's only a single text block, flatten it to a string + if len(openai_content) == 1 and openai_content[0]["type"] == "text": + messages.append( + {"role": role, "content": openai_content[0]["text"]} + ) + else: + messages.append({"role": role, "content": openai_content}) + else: + messages.append({"role": role, "content": str(content) if content else ""}) + + openai_payload["messages"] = messages + + # max_tokens + if "max_tokens" in anthropic_payload: + openai_payload["max_tokens"] = anthropic_payload["max_tokens"] + + # Common parameters + for param in ("temperature", "top_p", "stop_sequences", "stream"): + if param in anthropic_payload: + if param == "stop_sequences": + openai_payload["stop"] = anthropic_payload[param] + else: + openai_payload[param] = anthropic_payload[param] + + # Tools conversion: Anthropic → OpenAI + if "tools" in anthropic_payload: + openai_tools = [] + for tool in anthropic_payload["tools"]: + openai_tools.append( + { + "type": "function", + "function": { + "name": tool.get("name", ""), + "description": tool.get("description", ""), + "parameters": tool.get("input_schema", {}), + }, + } + ) + openai_payload["tools"] = openai_tools + + # tool_choice + if "tool_choice" in anthropic_payload: + tc = anthropic_payload["tool_choice"] + if isinstance(tc, dict): + tc_type = tc.get("type", "auto") + if tc_type == "auto": + openai_payload["tool_choice"] = "auto" + elif tc_type == "any": + openai_payload["tool_choice"] = "required" + elif tc_type == "tool": + openai_payload["tool_choice"] = { + "type": "function", + "function": {"name": tc.get("name", "")}, + } + + return openai_payload + + +def convert_openai_to_anthropic_response( + openai_response: dict, model: str = "" +) -> dict: + """ + Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format. + """ + import uuid as _uuid + + choice = {} + if openai_response.get("choices"): + choice = openai_response["choices"][0] + + message = choice.get("message", {}) + finish_reason = choice.get("finish_reason", "stop") + + # Map finish_reason to stop_reason + stop_reason_map = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + "content_filter": "end_turn", + } + stop_reason = stop_reason_map.get(finish_reason, "end_turn") + + # Build content blocks + content = [] + msg_content = message.get("content") + if msg_content: + content.append({"type": "text", "text": msg_content}) + + # Tool calls → tool_use blocks + tool_calls = message.get("tool_calls", []) + for tc in tool_calls: + func = tc.get("function", {}) + try: + tool_input = json.loads(func.get("arguments", "{}")) + except (json.JSONDecodeError, TypeError): + tool_input = {} + content.append( + { + "type": "tool_use", + "id": tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}"), + "name": func.get("name", ""), + "input": tool_input, + } + ) + + # Usage + openai_usage = openai_response.get("usage", {}) + usage = { + "input_tokens": openai_usage.get("prompt_tokens", 0), + "output_tokens": openai_usage.get("completion_tokens", 0), + } + + return { + "id": openai_response.get("id", f"msg_{_uuid.uuid4().hex[:24]}"), + "type": "message", + "role": "assistant", + "content": content, + "model": model or openai_response.get("model", ""), + "stop_reason": stop_reason, + "stop_sequence": None, + "usage": usage, + } + + +async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ""): + """ + Convert an OpenAI SSE streaming response to Anthropic Messages SSE format. + + OpenAI sends: data: {"choices": [{"delta": {"content": "..."}}]} + Anthropic sends: event: content_block_delta\\ndata: {"type": "content_block_delta", ...} + + Handles text content, tool calls, and mixed content with proper + multi-block indexing as required by Anthropic's streaming protocol. + """ + import uuid as _uuid + + msg_id = f"msg_{_uuid.uuid4().hex[:24]}" + input_tokens = 0 + output_tokens = 0 + stop_reason = "end_turn" + + # Track content blocks with a running index. + # Each text block or tool_use block gets its own index. + current_block_index = 0 + text_block_open = False + + # Track tool call state: maps OpenAI tool_call index -> Anthropic block index + # This allows handling multiple concurrent tool calls. + tool_call_blocks = {} # {openai_tc_index: anthropic_block_index} + tool_call_started = {} # {openai_tc_index: bool} + + # Emit message_start + message_start = { + "type": "message_start", + "message": { + "id": msg_id, + "type": "message", + "role": "assistant", + "content": [], + "model": model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 0, "output_tokens": 0}, + }, + } + yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n".encode() + + try: + async for chunk in openai_stream_generator: + if isinstance(chunk, bytes): + chunk = chunk.decode("utf-8", errors="ignore") + + for line in chunk.strip().split("\n"): + line = line.strip() + + if not line or not line.startswith("data:"): + continue + + data_str = line[5:].strip() + if data_str == "[DONE]": + continue + if data_str == "{}": + continue + + try: + data = json.loads(data_str) + except (json.JSONDecodeError, TypeError): + continue + + choices = data.get("choices", []) + if not choices: + # Check for usage in the final chunk + if data.get("usage"): + input_tokens = data["usage"].get("prompt_tokens", input_tokens) + output_tokens = data["usage"].get( + "completion_tokens", output_tokens + ) + continue + + delta = choices[0].get("delta", {}) + finish_reason = choices[0].get("finish_reason") + + # Update usage if present + if data.get("usage"): + input_tokens = data["usage"].get("prompt_tokens", input_tokens) + output_tokens = data["usage"].get( + "completion_tokens", output_tokens + ) + + # --- Handle text content --- + content = delta.get("content") + if content is not None: + if not text_block_open: + # Start a new text content block + block_start = { + "type": "content_block_start", + "index": current_block_index, + "content_block": {"type": "text", "text": ""}, + } + yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode() + text_block_open = True + + # Send text delta + block_delta = { + "type": "content_block_delta", + "index": current_block_index, + "delta": {"type": "text_delta", "text": content}, + } + yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode() + + # --- Handle tool calls --- + tool_calls = delta.get("tool_calls") + if tool_calls: + # Close text block if one is open (text comes before tools) + if text_block_open: + block_stop = { + "type": "content_block_stop", + "index": current_block_index, + } + yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode() + text_block_open = False + current_block_index += 1 + + for tc in tool_calls: + tc_index = tc.get("index", 0) + + if tc_index not in tool_call_started: + # First time seeing this tool call — emit content_block_start + tool_call_blocks[tc_index] = current_block_index + tool_call_started[tc_index] = True + + # Extract tool call ID and name from the first chunk + tc_id = tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}") + tc_name = tc.get("function", {}).get("name", "") + + block_start = { + "type": "content_block_start", + "index": current_block_index, + "content_block": { + "type": "tool_use", + "id": tc_id, + "name": tc_name, + "input": {}, + }, + } + yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode() + current_block_index += 1 + + # Emit argument chunks as input_json_delta + args_chunk = tc.get("function", {}).get("arguments", "") + if args_chunk: + block_delta = { + "type": "content_block_delta", + "index": tool_call_blocks[tc_index], + "delta": { + "type": "input_json_delta", + "partial_json": args_chunk, + }, + } + yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode() + + # --- Handle finish reason --- + if finish_reason is not None: + stop_reason_map = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + } + stop_reason = stop_reason_map.get(finish_reason, "end_turn") + + except Exception as e: + log.error(f"Error in Anthropic stream conversion: {e}") + + # Close any open text block + if text_block_open: + block_stop = {"type": "content_block_stop", "index": current_block_index} + yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode() + + # Close any open tool call blocks + for tc_index, block_index in tool_call_blocks.items(): + block_stop = {"type": "content_block_stop", "index": block_index} + yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode() + + # Emit message_delta with stop reason + message_delta = { + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": None, + }, + "usage": {"output_tokens": output_tokens}, + } + yield f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode() + + # Emit message_stop + yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n".encode() diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 542f469dfd..67ebb4956b 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -309,6 +309,10 @@ async def get_current_user( if token is None and "token" in request.cookies: token = request.cookies.get("token") + # Fallback to request.state.token (set by middleware, e.g. for x-api-key) + if token is None and hasattr(request.state, "token") and request.state.token: + token = request.state.token.credentials + if token is None: raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index 63d5fbb3ce..283f37ea66 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -12,13 +12,15 @@ AUDIT_LOG_FILE_ROTATION_SIZE, AUDIT_LOG_LEVEL, GLOBAL_LOG_LEVEL, + LOG_FORMAT, AUDIT_UVICORN_LOGGER_NAMES, ENABLE_OTEL, ENABLE_OTEL_LOGS, + _LEVEL_MAP, ) if TYPE_CHECKING: - from loguru import Record + from loguru import Message, Record def stdout_format(record: "Record") -> str: @@ -43,6 +45,29 @@ def stdout_format(record: "Record") -> str: ) +def _json_sink(message: "Message") -> None: + """Write log records as single-line JSON to stdout. + + Used as a Loguru sink when LOG_FORMAT is set to "json". + """ + record = message.record + log_entry = { + "ts": record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + "level": _LEVEL_MAP.get(record["level"].name, record["level"].name.lower()), + "msg": record["message"], + "caller": f"{record['name']}:{record['function']}:{record['line']}", + } + + if record["extra"]: + log_entry["extra"] = record["extra"] + + if record["exception"] is not None: + log_entry["error"] = "".join(record["exception"].format_exception()).rstrip() + + sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n") + sys.stdout.flush() + + class InterceptHandler(logging.Handler): """ Intercepts log records from Python's standard logging module @@ -127,14 +152,22 @@ def start_logger(): """ logger.remove() - logger.add( - sys.stdout, - level=GLOBAL_LOG_LEVEL, - format=stdout_format, - filter=lambda record: ( - "auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True - ), + audit_filter = lambda record: ( + "auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True ) + if LOG_FORMAT == "json": + logger.add( + _json_sink, + level=GLOBAL_LOG_LEVEL, + filter=audit_filter, + ) + else: + logger.add( + sys.stdout, + level=GLOBAL_LOG_LEVEL, + format=stdout_format, + filter=audit_filter, + ) if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE: try: logger.add( diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index 33803648be..1004536e4d 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -9,14 +9,27 @@ from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken import httpx -from mcp.shared._httpx_utils import create_mcp_http_client from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL def create_insecure_httpx_client(headers=None, timeout=None, auth=None): - client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth) - client.verify = False - return client + """Create an httpx AsyncClient with SSL verification disabled. + + Note: verify=False must be passed at construction time because httpx + configures the SSL context during __init__. Setting client.verify = False + after construction does not affect the underlying transport's SSL context. + """ + kwargs = { + "follow_redirects": True, + "verify": False, + } + if timeout is not None: + kwargs["timeout"] = timeout + if headers is not None: + kwargs["headers"] = headers + if auth is not None: + kwargs["auth"] = auth + return httpx.AsyncClient(**kwargs) class MCPClient: diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index ec7af7733b..763d74bd13 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -171,7 +171,10 @@ 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) + try: + tool_result = json.loads(tool_result) + except (json.JSONDecodeError, TypeError): + pass # keep tool_result as-is (e.g. fetch_url returns plain text) if isinstance(tool_result, dict) and "error" in tool_result: return [] @@ -232,6 +235,25 @@ def get_citation_source_from_tool_result( } ] + elif tool_name == "fetch_url": + url = tool_params.get("url", "") + content = tool_result if isinstance(tool_result, str) else str(tool_result) + snippet = content[:500] + ("..." if len(content) > 500 else "") + + return [ + { + "source": {"name": url or "fetch_url", "id": url or "fetch_url"}, + "document": [snippet], + "metadata": [ + { + "source": url, + "name": url, + "url": url, + } + ], + } + ] + elif tool_name == "query_knowledge_files": chunks = tool_result @@ -805,10 +827,15 @@ def apply_source_context_to_messages( messages: list, sources: list, user_message: str, + include_content: bool = True, ) -> list: """ Build source context from citation sources and apply to messages. Uses RAG template to format context for model consumption. + + When include_content is False, emit tags with id/name but no + document body — useful when the content is already present elsewhere + (e.g. in a tool result message) and only citation markers are needed. """ if not sources or not user_message: return messages @@ -822,10 +849,11 @@ def apply_source_context_to_messages( if src_id not in citation_idx: citation_idx[src_id] = len(citation_idx) + 1 src_name = source.get("source", {}).get("name") + body = doc if include_content else "" context_string += ( f'{doc}\n" + + f">{body}\n" ) context_string = context_string.strip() @@ -2056,11 +2084,12 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Folder "Project" handling # Check if the request has chat_id and is inside of a folder + # Uses lightweight column query — only fetches folder_id, not the full chat JSON blob chat_id = metadata.get("chat_id", None) if chat_id and user: - chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) - if chat and chat.folder_id: - folder = Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id) + folder_id = Chats.get_chat_folder_id(chat_id, user.id) + if folder_id: + folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id) if folder and folder.data: if "system_prompt" in folder.data: @@ -2197,6 +2226,10 @@ 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) + # Caller-provided OpenAI-style tools take precedence over server-side + # tool resolution (tool_ids, MCP servers, builtin tools). + payload_tools = form_data.get("tools", None) + # Skills user_skill_ids = set(form_data.pop("skill_ids", None) or []) model_skill_ids = set(model.get("info", {}).get("meta", {}).get("skillIds", [])) @@ -2268,228 +2301,235 @@ async def process_chat_payload(request, form_data, user, metadata, model): } form_data["metadata"] = metadata - # Server side tools - tool_ids = metadata.get("tool_ids", None) - # Client side tools - direct_tool_servers = metadata.get("tool_servers", None) + # When the caller provides an explicit OpenAI-style `tools` array in the + # request body, skip all server-side tool resolution and pass the caller's + # tools through to the model unchanged. + if not payload_tools: + # Server side tools + tool_ids = metadata.get("tool_ids", None) + # Client side tools + direct_tool_servers = metadata.get("tool_servers", None) - log.debug(f"{tool_ids=}") - log.debug(f"{direct_tool_servers=}") + log.debug(f"{tool_ids=}") + log.debug(f"{direct_tool_servers=}") - tools_dict = {} + tools_dict = {} - mcp_clients = {} - mcp_tools_dict = {} + mcp_clients = {} + mcp_tools_dict = {} - if tool_ids: - for tool_id in tool_ids: - if tool_id.startswith("server:mcp:"): - try: - server_id = tool_id[len("server:mcp:") :] + if tool_ids: + for tool_id in tool_ids: + if tool_id.startswith("server:mcp:"): + try: + server_id = tool_id[len("server:mcp:") :] - mcp_server_connection = None - for ( - server_connection - ) in request.app.state.config.TOOL_SERVER_CONNECTIONS: - if ( - server_connection.get("type", "") == "mcp" - and server_connection.get("info", {}).get("id") == server_id - ): - mcp_server_connection = server_connection - break + mcp_server_connection = None + for ( + server_connection + ) in request.app.state.config.TOOL_SERVER_CONNECTIONS: + if ( + server_connection.get("type", "") == "mcp" + and server_connection.get("info", {}).get("id") + == server_id + ): + mcp_server_connection = server_connection + break - if not mcp_server_connection: - log.error(f"MCP server with id {server_id} not found") - continue + if not mcp_server_connection: + log.error(f"MCP server with id {server_id} not found") + continue - # Check access control for MCP server - if not has_tool_server_access(user, mcp_server_connection): - log.warning( - f"Access denied to MCP server {server_id} for user {user.id}" - ) - continue + # Check access control for MCP server + if not has_tool_server_access(user, mcp_server_connection): + log.warning( + f"Access denied to MCP server {server_id} for user {user.id}" + ) + continue - auth_type = mcp_server_connection.get("auth_type", "") - headers = {} - if auth_type == "bearer": - headers["Authorization"] = ( - f"Bearer {mcp_server_connection.get('key', '')}" - ) - elif auth_type == "none": - # No authentication - pass - elif auth_type == "session": - headers["Authorization"] = ( - f"Bearer {request.state.token.credentials}" - ) - elif auth_type == "system_oauth": - oauth_token = extra_params.get("__oauth_token__", None) - if oauth_token: + auth_type = mcp_server_connection.get("auth_type", "") + headers = {} + if auth_type == "bearer": headers["Authorization"] = ( - f"Bearer {oauth_token.get('access_token', '')}" + f"Bearer {mcp_server_connection.get('key', '')}" ) - elif auth_type == "oauth_2.1": - try: - splits = server_id.split(":") - server_id = splits[-1] if len(splits) > 1 else server_id - - oauth_token = await request.app.state.oauth_client_manager.get_oauth_token( - user.id, f"mcp:{server_id}" + elif auth_type == "none": + # No authentication + pass + elif auth_type == "session": + headers["Authorization"] = ( + f"Bearer {request.state.token.credentials}" ) - + elif auth_type == "system_oauth": + oauth_token = extra_params.get("__oauth_token__", None) if oauth_token: headers["Authorization"] = ( f"Bearer {oauth_token.get('access_token', '')}" ) - except Exception as e: - log.error(f"Error getting OAuth token: {e}") - oauth_token = None - - connection_headers = mcp_server_connection.get("headers", None) - if connection_headers and isinstance(connection_headers, dict): - for key, value in connection_headers.items(): - headers[key] = value - - # Add user info headers if enabled - 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" - ) - if metadata and metadata.get("message_id"): - headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = ( - metadata.get("message_id") - ) + elif auth_type == "oauth_2.1": + try: + splits = server_id.split(":") + server_id = splits[-1] if len(splits) > 1 else server_id - mcp_clients[server_id] = MCPClient() - await mcp_clients[server_id].connect( - url=mcp_server_connection.get("url", ""), - headers=headers if headers else None, - ) + oauth_token = await request.app.state.oauth_client_manager.get_oauth_token( + user.id, f"mcp:{server_id}" + ) - function_name_filter_list = mcp_server_connection.get( - "config", {} - ).get("function_name_filter_list", "") + if oauth_token: + headers["Authorization"] = ( + f"Bearer {oauth_token.get('access_token', '')}" + ) + except Exception as e: + log.error(f"Error getting OAuth token: {e}") + oauth_token = None + + connection_headers = mcp_server_connection.get("headers", None) + if connection_headers and isinstance(connection_headers, dict): + for key, value in connection_headers.items(): + headers[key] = value + + # Add user info headers if enabled + 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") + ) + if metadata and metadata.get("message_id"): + headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = ( + metadata.get("message_id") + ) - if isinstance(function_name_filter_list, str): - function_name_filter_list = function_name_filter_list.split(",") + mcp_clients[server_id] = MCPClient() + await mcp_clients[server_id].connect( + url=mcp_server_connection.get("url", ""), + headers=headers if headers else None, + ) - tool_specs = await mcp_clients[server_id].list_tool_specs() - for tool_spec in tool_specs: + function_name_filter_list = mcp_server_connection.get( + "config", {} + ).get("function_name_filter_list", "") - def make_tool_function(client, function_name): - async def tool_function(**kwargs): - return await client.call_tool( - function_name, - function_args=kwargs, - ) + if isinstance(function_name_filter_list, str): + function_name_filter_list = function_name_filter_list.split( + "," + ) - return tool_function + tool_specs = await mcp_clients[server_id].list_tool_specs() + for tool_spec in tool_specs: - if function_name_filter_list: - if not is_string_allowed( - tool_spec["name"], function_name_filter_list - ): - # Skip this function - continue + def make_tool_function(client, function_name): + async def tool_function(**kwargs): + return await client.call_tool( + function_name, + function_args=kwargs, + ) - tool_function = make_tool_function( - mcp_clients[server_id], tool_spec["name"] - ) + return tool_function - mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = { - "spec": { - **tool_spec, - "name": f"{server_id}_{tool_spec['name']}", - }, - "callable": tool_function, - "type": "mcp", - "client": mcp_clients[server_id], - "direct": False, - } - except Exception as e: - log.debug(e) - if event_emitter: - await event_emitter( - { - "type": "chat:message:error", - "data": { - "error": { - "content": f"Failed to connect to MCP server '{server_id}'" - } + if function_name_filter_list: + if not is_string_allowed( + tool_spec["name"], function_name_filter_list + ): + # Skip this function + continue + + tool_function = make_tool_function( + mcp_clients[server_id], tool_spec["name"] + ) + + mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = { + "spec": { + **tool_spec, + "name": f"{server_id}_{tool_spec['name']}", }, + "callable": tool_function, + "type": "mcp", + "client": mcp_clients[server_id], + "direct": False, } - ) - continue - - tools_dict = await get_tools( - request, - tool_ids, - user, - { - **extra_params, - "__model__": models[task_model_id], - "__messages__": form_data["messages"], - "__files__": metadata.get("files", []), - }, - ) + except Exception as e: + log.debug(e) + if event_emitter: + await event_emitter( + { + "type": "chat:message:error", + "data": { + "error": { + "content": f"Failed to connect to MCP server '{server_id}'" + } + }, + } + ) + continue - if mcp_tools_dict: - tools_dict = {**tools_dict, **mcp_tools_dict} + tools_dict = await get_tools( + request, + tool_ids, + user, + { + **extra_params, + "__model__": models[task_model_id], + "__messages__": form_data["messages"], + "__files__": metadata.get("files", []), + }, + ) - if direct_tool_servers: - for tool_server in direct_tool_servers: - tool_specs = tool_server.pop("specs", []) + if mcp_tools_dict: + tools_dict = {**tools_dict, **mcp_tools_dict} - for tool in tool_specs: - tools_dict[tool["name"]] = { - "spec": tool, - "direct": True, - "server": tool_server, - } + if direct_tool_servers: + for tool_server in direct_tool_servers: + tool_specs = tool_server.pop("specs", []) - if mcp_clients: - metadata["mcp_clients"] = mcp_clients + for tool in tool_specs: + tools_dict[tool["name"]] = { + "spec": tool, + "direct": True, + "server": tool_server, + } - # Inject builtin tools for native function calling based on enabled features and model capability - # Check if builtin_tools capability is enabled for this model (defaults to True if not specified) - builtin_tools_enabled = ( - model.get("info", {}).get("meta", {}).get("capabilities") or {} - ).get("builtin_tools", True) - if ( - metadata.get("params", {}).get("function_calling") == "native" - and builtin_tools_enabled - ): - # Add file context to user messages - chat_id = metadata.get("chat_id") - form_data["messages"] = add_file_context( - form_data.get("messages", []), chat_id, user - ) - builtin_tools = get_builtin_tools( - request, - { - **extra_params, - "__event_emitter__": event_emitter, - "__skill_ids__": [ - s.id for s in available_skills if s.id not in user_skill_ids - ], - }, - features, - model, - ) - for name, tool_dict in builtin_tools.items(): - if name not in tools_dict: - tools_dict[name] = tool_dict - - if tools_dict: - if metadata.get("params", {}).get("function_calling") == "native": - # If the function calling is native, then call the tools function calling handler - metadata["tools"] = tools_dict - form_data["tools"] = [ - {"type": "function", "function": tool.get("spec", {})} - for tool in tools_dict.values() - ] + if mcp_clients: + metadata["mcp_clients"] = mcp_clients + + # Inject builtin tools for native function calling based on enabled features and model capability + # Check if builtin_tools capability is enabled for this model (defaults to True if not specified) + builtin_tools_enabled = ( + model.get("info", {}).get("meta", {}).get("capabilities") or {} + ).get("builtin_tools", True) + if ( + metadata.get("params", {}).get("function_calling") == "native" + and builtin_tools_enabled + ): + # Add file context to user messages + chat_id = metadata.get("chat_id") + form_data["messages"] = add_file_context( + form_data.get("messages", []), chat_id, user + ) + builtin_tools = get_builtin_tools( + request, + { + **extra_params, + "__event_emitter__": event_emitter, + "__skill_ids__": [ + s.id for s in available_skills if s.id not in user_skill_ids + ], + }, + features, + model, + ) + for name, tool_dict in builtin_tools.items(): + if name not in tools_dict: + tools_dict[name] = tool_dict + + if tools_dict: + if metadata.get("params", {}).get("function_calling") == "native": + # If the function calling is native, then call the tools function calling handler + metadata["tools"] = tools_dict + form_data["tools"] = [ + {"type": "function", "function": tool.get("spec", {})} + for tool in tools_dict.values() + ] else: # If the function calling is not native, then call the tools function calling handler @@ -3958,6 +3998,8 @@ async def flush_pending_delta_data(threshold: int = 0): tool_call_retries = 0 tool_call_sources = [] # Track citation sources from tool results + all_tool_call_sources = [] # Accumulated sources across all iterations + user_message = get_last_user_message(form_data["messages"]) while ( len(tool_calls) > 0 @@ -4102,6 +4144,7 @@ async def flush_pending_delta_data(threshold: int = 0): tool_function_name in [ "search_web", + "fetch_url", "view_knowledge_file", "query_knowledge_files", ] @@ -4193,16 +4236,22 @@ async def flush_pending_delta_data(threshold: int = 0): await event_emitter({"type": "source", "data": source}) # Apply source context to messages for model - if tool_call_sources: - user_msg = get_last_user_message(form_data["messages"]) - if user_msg: - form_data["messages"] = apply_source_context_to_messages( - request, - form_data["messages"], - tool_call_sources, - user_msg, - ) - tool_call_sources.clear() + # Use metadata_only=True to avoid duplicating content + # that is already in the tool result message. + all_tool_call_sources.extend(tool_call_sources) + if all_tool_call_sources and user_message: + # Restore original user message before re-applying to avoid recursive nesting + form_data["messages"] = add_or_update_user_message( + user_message, form_data["messages"], append=False + ) + form_data["messages"] = apply_source_context_to_messages( + request, + form_data["messages"], + all_tool_call_sources, + user_message, + include_content=False, + ) + tool_call_sources.clear() await event_emitter( { diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 4425e013a0..c4641d9bdb 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -90,14 +90,22 @@ def get_message_list(messages_map, message_id): # Reconstruct the chain by following the parentId links message_list = [] + visited_message_ids = set() while current_message: - message_list.insert( - 0, current_message - ) # Insert the message at the beginning of the list + message_id = current_message.get("id") + if message_id in visited_message_ids: + # Cycle detected, break to prevent infinite loop + break + + if message_id is not None: + visited_message_ids.add(message_id) + + message_list.append(current_message) parent_id = current_message.get("parentId") # Use .get() for safety current_message = messages_map.get(parent_id) if parent_id else None + message_list.reverse() return message_list diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index e2f9e5bcad..6040c0645d 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -1,3 +1,4 @@ +import copy import time import logging import asyncio @@ -307,6 +308,29 @@ def get_filter_items_from_module(function, module): except Exception as e: log.info(f"Failed to load function module for {function_id}: {e}") + # Apply global model defaults to all models + # Per-model overrides take precedence over global defaults + default_metadata = ( + getattr(request.app.state.config, "DEFAULT_MODEL_METADATA", None) or {} + ) + + if default_metadata: + for model in models: + info = model.get("info") + + if info is None: + model["info"] = {"meta": copy.deepcopy(default_metadata)} + continue + + meta = info.setdefault("meta", {}) + for key, value in default_metadata.items(): + if key == "capabilities": + # Merge capabilities: defaults as base, per-model overrides win + existing = meta.get("capabilities") or {} + meta["capabilities"] = {**value, **existing} + elif meta.get(key) is None: + meta[key] = copy.deepcopy(value) + for model in models: action_ids = [ action_id @@ -435,7 +459,7 @@ def get_filtered_models(models, user, db=None): if model_info: if ( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) - or user.id == model_info["user_id"] + or user.id == model_info.get("user_id") or model["id"] in accessible_model_ids ): filtered_models.append(model) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index c849eb25a8..b23c5c90a3 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -41,6 +41,7 @@ ENABLE_OAUTH_ROLE_MANAGEMENT, ENABLE_OAUTH_GROUP_MANAGEMENT, ENABLE_OAUTH_GROUP_CREATION, + OAUTH_GROUP_DEFAULT_SHARE, OAUTH_BLOCKED_GROUPS, OAUTH_GROUPS_SEPARATOR, OAUTH_ROLES_SEPARATOR, @@ -69,6 +70,7 @@ ENABLE_OAUTH_ID_TOKEN_COOKIE, ENABLE_OAUTH_EMAIL_FALLBACK, OAUTH_CLIENT_INFO_ENCRYPTION_KEY, + OAUTH_MAX_SESSIONS_PER_USER, ) from open_webui.utils.misc import parse_duration from open_webui.utils.auth import get_password_hash, create_token @@ -113,6 +115,7 @@ class OAuthClientInformationFull(OAuthClientMetadata): auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION +auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE = OAUTH_GROUP_DEFAULT_SHARE auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM @@ -1245,7 +1248,11 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): name=group_name, description=f"Group '{group_name}' created automatically via OAuth.", permissions=default_permissions, # Use default permissions from function args - user_ids=[], # Start with no users, user will be added later by subsequent logic + data={ + "config": { + "share": auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE + } + }, ) # Use determined creator ID (admin or fallback to current user) created_group = Groups.insert_new_group( @@ -1679,11 +1686,18 @@ async def handle_callback(self, request, provider, response, db=None): if "expires_in" in token and "expires_at" not in token: token["expires_at"] = datetime.now().timestamp() + token["expires_in"] - # Clean up any existing sessions for this user/provider first + # Enforce max concurrent sessions per user/provider to prevent + # unbounded growth while allowing multi-device usage sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db) - for session in sessions: - if session.provider == provider: - OAuthSessions.delete_session_by_id(session.id, db=db) + provider_sessions = sorted( + [session for session in sessions if session.provider == provider], + key=lambda session: session.created_at, + reverse=True, + ) + # Keep the newest sessions up to the limit, prune the rest + if len(provider_sessions) >= OAUTH_MAX_SESSIONS_PER_USER: + for old_session in provider_sessions[OAUTH_MAX_SESSIONS_PER_USER - 1 :]: + OAuthSessions.delete_session_by_id(old_session.id, db=db) session = OAuthSessions.create_session( user_id=user.id, diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 318b8f8f88..168ec893b2 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -187,7 +187,7 @@ def parse_json(value: str) -> dict: ollama_root_params = { "format": lambda x: parse_json(x), "keep_alive": lambda x: parse_json(x), - "think": bool, + "think": lambda x: x, } for key, value in ollama_root_params.items(): @@ -326,7 +326,7 @@ def parse_json(value: str) -> dict: ollama_root_params = { "format": lambda x: parse_json(x), "keep_alive": lambda x: parse_json(x), - "think": bool, + "think": lambda x: x, } # Ollama's options field can contain parameters that should be at the root level. diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 2dd49fb8ff..a2d7b9ad11 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -8,7 +8,12 @@ import logging from typing import Any -from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE +from open_webui.env import ( + PIP_OPTIONS, + PIP_PACKAGE_INDEX_OPTIONS, + OFFLINE_MODE, + ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS, +) from open_webui.models.functions import Functions from open_webui.models.tools import Tools @@ -401,6 +406,12 @@ def get_function_module_from_cache(request, function_id, load_from_db=True): def install_frontmatter_requirements(requirements: str): + if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS: + log.info( + "ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements." + ) + return + if OFFLINE_MODE: log.info("Offline mode enabled, skipping installation of requirements.") return diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index aeba213392..310fa999c7 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -60,6 +60,8 @@ search_memories, add_memory, replace_memory_content, + delete_memory, + list_memories, get_current_timestamp, calculate_timestamp, search_notes, @@ -471,7 +473,15 @@ def is_builtin_tool_enabled(category: str) -> bool: # Add memory tools if builtin category enabled AND enabled for this chat if is_builtin_tool_enabled("memory") and features.get("memory"): - builtin_functions.extend([search_memories, add_memory, replace_memory_content]) + builtin_functions.extend( + [ + search_memories, + add_memory, + replace_memory_content, + delete_memory, + list_memories, + ] + ) # Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability if ( diff --git a/package-lock.json b/package-lock.json index 7cff57f5f1..abb5f71c2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.3.1", + "version": "0.8.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.3.1", + "version": "0.8.5.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index 711d0132c9..96c1d96a11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.3.1", + "version": "0.8.5.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/src/app.html b/src/app.html index faff689e7a..6decff8988 100644 --- a/src/app.html +++ b/src/app.html @@ -26,7 +26,7 @@ diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index 42e77c0afa..05f273c306 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -281,7 +281,12 @@ export const updateModelById = async (token: string, id: string, model: object) return res; }; -export const updateModelAccessGrants = async (token: string, id: string, accessGrants: any[]) => { +export const updateModelAccessGrants = async ( + token: string, + id: string, + name: string, + accessGrants: any[] +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/access/update`, { @@ -291,7 +296,7 @@ export const updateModelAccessGrants = async (token: string, id: string, accessG 'Content-Type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify({ id, access_grants: accessGrants }) + body: JSON.stringify({ id, name, access_grants: accessGrants }) }) .then(async (res) => { if (!res.ok) throw await res.json(); diff --git a/src/lib/apis/prompts/index.ts b/src/lib/apis/prompts/index.ts index 1fd311c76f..5db7dc3540 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -395,6 +395,34 @@ export const setProductionPromptVersion = async ( return res; }; +export const togglePromptById = async (token: string, promptId: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deletePromptById = async (token: string, promptId: string) => { let error = null; diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index a455627e11..8a8e269aea 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -309,14 +309,27 @@ bind:value={url} placeholder={$i18n.t('API Base URL')} autocomplete="off" + list={ollama ? undefined : 'suggestions'} required /> + + {#if !ollama} + + + {/if}
@@ -64,9 +62,9 @@ {#if changelog} {#each Object.keys(changelog) as version}
-
+

v{version} - {changelog[version].date} -

+
diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte index a8e525e976..fff9c12acf 100644 --- a/src/lib/components/ImportModal.svelte +++ b/src/lib/components/ImportModal.svelte @@ -67,6 +67,7 @@
{$i18n.t('Import')}
-
+
diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index 98877d1d43..277cb53398 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -158,6 +158,11 @@ if (modelOrderBy === 'name') { return modelDirection === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); } + if (modelOrderBy === 'tokens') { + const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0; + const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0; + return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens; + } return modelDirection === 'asc' ? a.count - b.count : b.count - a.count; }); @@ -167,6 +172,11 @@ const nameB = b.name || b.user_id; return userDirection === 'asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA); } + if (userOrderBy === 'tokens') { + const aTokens = a.total_tokens ?? 0; + const bTokens = b.total_tokens ?? 0; + return userDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens; + } return userDirection === 'asc' ? a.count - b.count : b.count - a.count; }); @@ -191,7 +201,7 @@ {#if groups.length > 0} {#each periods as period} @@ -329,8 +339,42 @@ {/if}
- {$i18n.t('Tokens')} - % + toggleModelSort('tokens')} + > +
+ {$i18n.t('Tokens')} + {#if modelOrderBy === 'tokens'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+ + toggleModelSort('percentage')} + > +
+ % + {#if modelOrderBy === 'percentage'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+ @@ -349,6 +393,9 @@ src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}" alt={model.name} class="size-5 rounded-full object-cover shrink-0" + on:error={(e) => { + e.target.src = '/favicon.png'; + }} /> {model.name}
@@ -422,7 +469,24 @@ {/if} - {$i18n.t('Tokens')} + toggleUserSort('tokens')} + > +
+ {$i18n.t('Tokens')} + {#if userOrderBy === 'tokens'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+ @@ -435,6 +499,9 @@ src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image" alt={user.name || 'User'} class="size-5 rounded-full object-cover shrink-0" + on:error={(e) => { + e.target.src = '/user.png'; + }} /> {user.name || user.email || user.user_id.substring(0, 8)} - {$i18n.t('Share')} + toggleSort('percentage')} + > +
+ {$i18n.t('Share')} + {#if orderBy === 'percentage'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+ @@ -130,7 +145,10 @@ {model.name} { + e.target.src = '/favicon.png'; + }} /> {model.name} diff --git a/src/lib/components/admin/Analytics/UserUsage.svelte b/src/lib/components/admin/Analytics/UserUsage.svelte index 09be24e426..f040222496 100644 --- a/src/lib/components/admin/Analytics/UserUsage.svelte +++ b/src/lib/components/admin/Analytics/UserUsage.svelte @@ -109,7 +109,22 @@ {/if} - {$i18n.t('Share')} + toggleSort('percentage')} + > +
+ {$i18n.t('Share')} + {#if orderBy === 'percentage'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+ diff --git a/src/lib/components/admin/Evaluations.svelte b/src/lib/components/admin/Evaluations.svelte index b55f096ee9..98a328730b 100644 --- a/src/lib/components/admin/Evaluations.svelte +++ b/src/lib/components/admin/Evaluations.svelte @@ -54,15 +54,14 @@ id="users-tabs-container" class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none" > - + - +
diff --git a/src/lib/components/admin/Evaluations/FeedbackMenu.svelte b/src/lib/components/admin/Evaluations/FeedbackMenu.svelte index 515408e463..cb1e6d165f 100644 --- a/src/lib/components/admin/Evaluations/FeedbackMenu.svelte +++ b/src/lib/components/admin/Evaluations/FeedbackMenu.svelte @@ -32,7 +32,7 @@ transition={flyAndScale} > { dispatch('delete'); show = false; diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte index abe0f952e4..a5deba0335 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -180,7 +180,10 @@ {model.name} { + e.target.src = '/favicon.png'; + }} /> { - filteredItems = functions + filteredItems = (functions ?? []) .filter( (f) => (selectedType !== '' ? f.type === selectedType : true) && @@ -681,7 +681,8 @@ } toast.success($i18n.t('Functions imported successfully')); - functions.set(await getFunctions(localStorage.token)); + functions = await getFunctionList(localStorage.token); + _functions.set(await getFunctions(localStorage.token)); models.set( await getModels( localStorage.token, @@ -690,6 +691,8 @@ true ) ); + importFiles = null; + functionsImportInputElement.value = ''; }; reader.readAsText(importFiles[0]); diff --git a/src/lib/components/admin/Functions/FunctionMenu.svelte b/src/lib/components/admin/Functions/FunctionMenu.svelte index 20406d1299..422759653d 100644 --- a/src/lib/components/admin/Functions/FunctionMenu.svelte +++ b/src/lib/components/admin/Functions/FunctionMenu.svelte @@ -67,7 +67,7 @@ {/if} { editHandler(); }} @@ -91,7 +91,7 @@ { shareHandler(); }} @@ -101,7 +101,7 @@ { cloneHandler(); }} @@ -112,7 +112,7 @@ { exportHandler(); }} @@ -125,7 +125,7 @@
{ deleteHandler(); }} diff --git a/src/lib/components/admin/Settings.svelte b/src/lib/components/admin/Settings.svelte index 648caef298..8aa2c8d278 100644 --- a/src/lib/components/admin/Settings.svelte +++ b/src/lib/components/admin/Settings.svelte @@ -329,15 +329,14 @@ {#each filteredSettings as tab (tab.id)} - + {/each}
diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index 985baa0e9b..064cd00c67 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -235,7 +235,7 @@
{$i18n.t('Speech-to-Text Engine')}
{ @@ -798,7 +798,7 @@
{$i18n.t('Response splitting')}
@@ -277,7 +277,7 @@
-
+
+ + + { + config.enable = config.enable ?? false; + onSubmit({ url, key: config?.key ?? '', config }); + }} + /> +
diff --git a/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte b/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte index a4c2ada059..da9038457a 100644 --- a/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte +++ b/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte @@ -3,6 +3,7 @@ const i18n = getContext('i18n'); import Tooltip from '$lib/components/common/Tooltip.svelte'; + import Switch from '$lib/components/common/Switch.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte'; import AddConnectionModal from '$lib/components/AddConnectionModal.svelte'; @@ -98,10 +99,10 @@
-
+
+ + + { + config.enable = config.enable ?? false; + onSubmit({ url, key, config }); + }} + /> +
diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 3f4434084b..78e300ae04 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -42,6 +42,7 @@ let RAG_EMBEDDING_MODEL = ''; let RAG_EMBEDDING_BATCH_SIZE = 1; let ENABLE_ASYNC_EMBEDDING = true; + let RAG_EMBEDDING_CONCURRENT_REQUESTS = 0; let rerankingModel = ''; @@ -104,7 +105,8 @@ RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_MODEL, RAG_EMBEDDING_BATCH_SIZE, - ENABLE_ASYNC_EMBEDDING + ENABLE_ASYNC_EMBEDDING, + RAG_EMBEDDING_CONCURRENT_REQUESTS }); updateEmbeddingModelLoading = true; @@ -113,6 +115,7 @@ RAG_EMBEDDING_MODEL: RAG_EMBEDDING_MODEL, RAG_EMBEDDING_BATCH_SIZE: RAG_EMBEDDING_BATCH_SIZE, ENABLE_ASYNC_EMBEDDING: ENABLE_ASYNC_EMBEDDING, + RAG_EMBEDDING_CONCURRENT_REQUESTS: RAG_EMBEDDING_CONCURRENT_REQUESTS, ollama_config: { key: OllamaKey, url: OllamaUrl @@ -241,6 +244,7 @@ RAG_EMBEDDING_MODEL = embeddingConfig.RAG_EMBEDDING_MODEL; RAG_EMBEDDING_BATCH_SIZE = embeddingConfig.RAG_EMBEDDING_BATCH_SIZE ?? 1; ENABLE_ASYNC_EMBEDDING = embeddingConfig.ENABLE_ASYNC_EMBEDDING ?? true; + RAG_EMBEDDING_CONCURRENT_REQUESTS = embeddingConfig.RAG_EMBEDDING_CONCURRENT_REQUESTS ?? 0; OpenAIKey = embeddingConfig.openai_config.key; OpenAIUrl = embeddingConfig.openai_config.url; @@ -336,7 +340,7 @@
@@ -548,7 +552,7 @@
{ // Auto-update URL when switching modes if it's empty or matches the opposite mode's default @@ -761,7 +765,7 @@
{$i18n.t('Text Splitter')}
{ @@ -1043,6 +1047,28 @@
+ +
+
+ + {$i18n.t('Embedding Concurrent Requests')} + +
+
+ +
+
{/if}
@@ -1099,7 +1125,7 @@
import DOMPurify from 'dompurify'; + import { v4 as uuidv4 } from 'uuid'; - import { getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis'; + import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis'; import { getAdminConfig, getLdapConfig, @@ -10,16 +11,19 @@ updateLdapConfig, updateLdapServer } from '$lib/apis/auths'; + import { getBanners, setBanners } from '$lib/apis/configs'; import { getGroups } from '$lib/apis/groups'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Switch from '$lib/components/common/Switch.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import { WEBUI_BUILD_HASH, WEBUI_VERSION } from '$lib/constants'; - import { config, showChangelog } from '$lib/stores'; + import { banners as _banners, config, showChangelog } from '$lib/stores'; + import type { Banner } from '$lib/types'; import { compareVersion } from '$lib/utils'; import { onMount, getContext } from 'svelte'; import { toast } from 'svelte-sonner'; import Textarea from '$lib/components/common/Textarea.svelte'; + import Banners from './Interface/Banners.svelte'; const i18n = getContext('i18n'); @@ -35,6 +39,8 @@ let webhookUrl = ''; let groups = []; + let banners: Banner[] = []; + // LDAP let ENABLE_LDAP = false; let LDAP_SERVER = { @@ -78,12 +84,20 @@ } }; + const updateBanners = async () => { + _banners.set(await setBanners(localStorage.token, banners)); + }; + const updateHandler = async () => { webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl); const res = await updateAdminConfig(localStorage.token, adminConfig); await updateLdapConfig(localStorage.token, ENABLE_LDAP); await updateLdapServerHandler(); + await updateBanners(); + + await config.set(await getBackendConfig()); + if (res) { saveHandler(); } else { @@ -114,6 +128,8 @@ const ldapConfig = await getLdapConfig(localStorage.token); ENABLE_LDAP = ldapConfig.ENABLE_LDAP; + + banners = await getBanners(localStorage.token); }); @@ -293,7 +309,7 @@
{$i18n.t('Default User Role')}
@@ -629,7 +645,6 @@ > @@ -641,6 +656,7 @@
@@ -914,6 +930,53 @@ + +
+
{$i18n.t('UI')}
+ +
+ +
+
+
+ {$i18n.t('Banners')} +
+ + +
+ + +
+
{/if} diff --git a/src/lib/components/admin/Settings/Images.svelte b/src/lib/components/admin/Settings/Images.svelte index e3c3a2ca4a..bf3ce6da19 100644 --- a/src/lib/components/admin/Settings/Images.svelte +++ b/src/lib/components/admin/Settings/Images.svelte @@ -402,7 +402,7 @@ @@ -950,7 +950,7 @@ { + if (importFiles.length > 0) { + const reader = new FileReader(); + reader.onload = async (event) => { + modelsImportInProgress = true; + + try { + const models = JSON.parse(String(event.target.result)); + const res = await importModels(localStorage.token, models); + + if (res) { + toast.success($i18n.t('Models imported successfully')); + await init(); + } else { + toast.error($i18n.t('Failed to import models')); + } + } catch (e) { + toast.error(e?.detail ?? $i18n.t('Invalid JSON file')); + console.error(e); + } + + modelsImportInProgress = false; + }; + reader.readAsText(importFiles[0]); + } + }} + /> + + + + + {/if} + - - - - - {/if} {:else} { + onBack={async () => { selectedModelId = null; + await init(); }} /> {/if} diff --git a/src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte b/src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte deleted file mode 100644 index a48335a8de..0000000000 --- a/src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte +++ /dev/null @@ -1,242 +0,0 @@ - - - { - const res = deleteAllModels(localStorage.token); - if (res) { - toast.success($i18n.t('All models deleted successfully')); - initHandler(); - } - }} -/> - - -
-
-
- {$i18n.t('Settings')} -
- -
- -
-
- {#if config} -
{ - submitHandler(); - }} - > -
-
- - - -
-
- -
- - - -
- - - -
- - - - - -
- - {:else} -
- -
- {/if} -
-
-
-
diff --git a/src/lib/components/admin/Settings/Models/ModelMenu.svelte b/src/lib/components/admin/Settings/Models/ModelMenu.svelte index d4cd48a37d..cf582bb497 100644 --- a/src/lib/components/admin/Settings/Models/ModelMenu.svelte +++ b/src/lib/components/admin/Settings/Models/ModelMenu.svelte @@ -56,7 +56,7 @@ transition={flyAndScale} > { hideHandler(); }} @@ -108,7 +108,7 @@ { pinModelHandler(model?.id); }} @@ -129,7 +129,7 @@ { copyLinkHandler(); }} @@ -139,19 +139,21 @@
{$i18n.t('Copy Link')}
- { - cloneHandler(); - }} - > - + {#if model?.is_active ?? true} + { + cloneHandler(); + }} + > + -
{$i18n.t('Clone')}
-
+
{$i18n.t('Clone')}
+
+ {/if} { exportHandler(); }} diff --git a/src/lib/components/admin/Settings/Models/ModelSelector.svelte b/src/lib/components/admin/Settings/Models/ModelSelector.svelte index d2871f1fee..d76326aa05 100644 --- a/src/lib/components/admin/Settings/Models/ModelSelector.svelte +++ b/src/lib/components/admin/Settings/Models/ModelSelector.svelte @@ -3,8 +3,10 @@ const i18n = getContext('i18n'); import Minus from '$lib/components/icons/Minus.svelte'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; export let title = ''; + export let tooltip = ''; export let models = []; export let modelIds = []; @@ -14,12 +16,32 @@
-
{title}
+
+ {title} + {#if tooltip} + + + + + + {/if} +
+ {:else if webConfig.WEB_SEARCH_ENGINE === 'youcom'} +
+
+
+ {$i18n.t('You.com API Key')} +
+ + +
+
{/if} {#if webConfig.WEB_SEARCH_ENGINE === 'duckduckgo'} @@ -936,7 +950,7 @@
+
+
+
+
+
- -
- + + {#if query} +
- -
+
+ {/if}
-
-
- -
- {#if filteredGroups.length === 0} -
-
- {$i18n.t('Organize your users')} -
-
- {$i18n.t('Use groups to group your users and assign permissions.')} -
+ item.value === sortBy)} + items={sortItems} + onSelectedChange={(selectedItem) => { + sortBy = selectedItem.value; + }} + > + + + + + + + {#each sortItems as item} + + {item.label} + + {#if sortBy === item.value} +
+ +
+ {/if} +
+ {/each} +
+
+
-
- -
+ {#if filteredGroups.length !== 0} +
+ {#each filteredGroups as group} + + {/each}
{:else} -
-
-
{$i18n.t('Group')}
- -
{$i18n.t('Users')}
-
- -
- - {#each filteredGroups as group} -
- +
+
+
👥
+
{$i18n.t('No groups found')}
+
+ {$i18n.t('Use groups to organize your users and assign permissions.')}
- {/each} +
{/if} +
-
- - - - -
+
+ +
+ {/if} diff --git a/src/lib/components/admin/Users/Groups/General.svelte b/src/lib/components/admin/Users/Groups/General.svelte index 1922f00138..4d32b3148b 100644 --- a/src/lib/components/admin/Users/Groups/General.svelte +++ b/src/lib/components/admin/Users/Groups/General.svelte @@ -77,7 +77,7 @@
@@ -381,6 +382,7 @@
{/if}
--> - + diff --git a/src/lib/components/channel/MessageInput/InputMenu.svelte b/src/lib/components/channel/MessageInput/InputMenu.svelte index c94b8f9a23..46c0bcb847 100644 --- a/src/lib/components/channel/MessageInput/InputMenu.svelte +++ b/src/lib/components/channel/MessageInput/InputMenu.svelte @@ -54,7 +54,7 @@ transition={flyAndScale} > { uploadFilesHandler(); }} @@ -64,7 +64,7 @@ { screenCaptureHandler(); }} diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 4782a22989..554cc8db40 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -121,6 +121,7 @@ let eventConfirmationInput = false; let eventConfirmationInputPlaceholder = ''; let eventConfirmationInputValue = ''; + let eventConfirmationInputType = ''; let eventCallback = null; let chatIdUnsubscriber: Unsubscriber | undefined; @@ -330,24 +331,34 @@ // Set Default Features if (model?.info?.meta?.defaultFeatureIds) { - if (model.info?.meta?.capabilities?.['image_generation']) { + if ( + model.info?.meta?.capabilities?.['image_generation'] && + $config?.features?.enable_image_generation && + ($user?.role === 'admin' || $user?.permissions?.features?.image_generation) + ) { imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation'); } - if (model.info?.meta?.capabilities?.['web_search']) { + if ( + model.info?.meta?.capabilities?.['web_search'] && + $config?.features?.enable_web_search && + ($user?.role === 'admin' || $user?.permissions?.features?.web_search) + ) { webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search'); } - if (model.info?.meta?.capabilities?.['code_interpreter']) { + if ( + model.info?.meta?.capabilities?.['code_interpreter'] && + $config?.features?.enable_code_interpreter && + ($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter) + ) { codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter'); } } } }; - const showMessage = async (message, ignoreSettings = false) => { - await tick(); - + const showMessage = async (message, scroll = true) => { const _chatId = JSON.parse(JSON.stringify($chatId)); let _messageId = JSON.parse(JSON.stringify(message.id)); @@ -367,18 +378,19 @@ history.currentId = _messageId; - await tick(); - await tick(); await tick(); - if (($settings?.scrollOnBranchChange ?? true) || ignoreSettings) { + if (($settings?.scrollOnBranchChange ?? true) && scroll) { const messageElement = document.getElementById(`message-${message.id}`); if (messageElement) { - messageElement.scrollIntoView({ behavior: 'smooth' }); + messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } await tick(); + await tick(); + await tick(); + saveChatHandler(_chatId, history); }; @@ -416,6 +428,15 @@ message.files = data.files; } else if (type === 'chat:message:embeds' || type === 'embeds') { message.embeds = data.embeds; + + // Auto-scroll to the embed once it's rendered in the DOM + await tick(); + setTimeout(() => { + const embedEl = document.getElementById(`${event.message_id}-embeds-container`); + if (embedEl) { + embedEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); } else if (type === 'chat:message:error') { message.error = data.error; } else if (type === 'chat:message:follow_ups') { @@ -505,6 +526,7 @@ eventConfirmationMessage = data.message; eventConfirmationInputPlaceholder = data.placeholder; eventConfirmationInputValue = data?.value ?? ''; + eventConfirmationInputType = data?.type ?? ''; } else { console.log('Unknown message type', data); } @@ -812,6 +834,11 @@ }; const uploadWeb = async (urls) => { + if ($user?.role !== 'admin' && !($user?.permissions?.chat?.web_upload ?? true)) { + toast.error($i18n.t('You do not have permission to upload web content.')); + return; + } + if (!Array.isArray(urls)) { urls = [urls]; } @@ -1011,12 +1038,16 @@ if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) { if (availableModels.length > 0) { if (defaultModels && defaultModels.length > 0) { - // Set from default models selectedModels = defaultModels.filter((modelId) => availableModels.includes(modelId)); } - // Set to first available model - selectedModels = [availableModels?.at(0) ?? '']; + if ( + selectedModels.length === 0 || + (selectedModels.length === 1 && selectedModels[0] === '') + ) { + // Only fall back to first available model if default models didn't resolve + selectedModels = [availableModels?.at(0) ?? '']; + } } else { selectedModels = ['']; } @@ -1044,6 +1075,8 @@ chatFiles = []; params = {}; + taskIds = null; + messageQueue = []; if ($page.url.searchParams.get('youtube')) { await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`); @@ -2522,6 +2555,7 @@ input={eventConfirmationInput} inputPlaceholder={eventConfirmationInputPlaceholder} inputValue={eventConfirmationInputValue} + inputType={eventConfirmationInputType} on:confirm={(e) => { if (e.detail) { eventCallback(e.detail); diff --git a/src/lib/components/chat/ChatControls/Embeds.svelte b/src/lib/components/chat/ChatControls/Embeds.svelte index 126124bc69..2f9dd6dae7 100644 --- a/src/lib/components/chat/ChatControls/Embeds.svelte +++ b/src/lib/components/chat/ChatControls/Embeds.svelte @@ -57,6 +57,7 @@ diff --git a/src/lib/components/chat/Settings/Account.svelte b/src/lib/components/chat/Settings/Account.svelte index d51a49fdf5..e0dc1407f2 100644 --- a/src/lib/components/chat/Settings/Account.svelte +++ b/src/lib/components/chat/Settings/Account.svelte @@ -150,6 +150,7 @@ class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden" type="text" bind:value={name} + aria-label={$i18n.t('Name')} required placeholder={$i18n.t('Enter your name')} /> @@ -164,6 +165,7 @@ className="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden" minSize={60} bind:value={bio} + ariaLabel={$i18n.t('Bio')} placeholder={$i18n.t('Share your background and interests')} /> @@ -174,8 +176,9 @@
@@ -232,6 +237,7 @@ class="w-full text-sm outline-hidden" type="url" placeholder={$i18n.t('Enter your webhook URL')} + aria-label={$i18n.t('Notification Webhook')} bind:value={webhookUrl} required /> @@ -273,6 +279,7 @@
+ + {#if typeof params.think === 'string'} +
+
+ +
+
+ {/if}
diff --git a/src/lib/components/chat/Settings/Audio.svelte b/src/lib/components/chat/Settings/Audio.svelte index 1649152fa5..a638e9ddfa 100644 --- a/src/lib/components/chat/Settings/Audio.svelte +++ b/src/lib/components/chat/Settings/Audio.svelte @@ -184,8 +184,9 @@
{$i18n.t('Speech-to-Text Engine')}
@@ -226,6 +228,8 @@ toggleSpeechAutoSend(); }} type="button" + role="switch" + aria-checked={speechAutoSend} > {#if speechAutoSend === true} {$i18n.t('On')} @@ -243,8 +247,9 @@
{$i18n.t('Text-to-Speech Engine')}
@@ -281,6 +287,8 @@ toggleResponseAutoPlayback(); }} type="button" + role="switch" + aria-checked={responseAutoPlayback} > {#if responseAutoPlayback === true} {$i18n.t('On')} @@ -299,6 +307,7 @@ min="0" step="0.01" bind:value={playbackRate} + aria-label={$i18n.t('Speech Playback Speed')} class=" text-sm text-right bg-transparent dark:text-gray-300 outline-hidden" /> x @@ -318,6 +327,7 @@ list="voice-list" class="w-full text-sm bg-transparent dark:text-gray-300 outline-hidden" bind:value={voice} + aria-label={$i18n.t('Voice')} placeholder={$i18n.t('Select a voice')} /> @@ -353,8 +363,9 @@
{$i18n.t('Language')}
@@ -143,12 +145,14 @@