diff --git a/.env.example b/.env.example index 6f674e6e50..22b128ce8f 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,12 @@ CORS_ALLOW_ORIGIN='*' # Set to false to keep memory tools enabled without adding memory context to the system context. ENABLE_MEMORY_SYSTEM_CONTEXT=true +# Set to true to add compact row/column stats to parsed CSV retrieval context. +ENABLE_RAG_CSV_SUMMARY=false + +# Set to true to preserve backing file records, storage blobs, and per-file vectors when files are removed from knowledge bases. +ENABLE_KNOWLEDGE_FILE_RETENTION=false + # Set to false to disable workspace Tools and Functions. ENABLE_PLUGINS=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 420633a0f6..1c43cb2058 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -2,7 +2,6 @@ name: Bug Report description: Create a detailed bug report to help us improve Open WebUI. title: 'issue: ' labels: ['bug', 'triage'] -assignees: [] body: - type: markdown attributes: @@ -35,6 +34,8 @@ body: required: true - label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!). required: true + - label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source. + required: true - label: I am using the latest version of Open WebUI. required: true @@ -55,7 +56,7 @@ body: id: open-webui-version attributes: label: Open WebUI Version - description: Specify the version (e.g., v0.6.26) + description: Specify the version (e.g., v0.11.0) validations: required: true @@ -63,7 +64,7 @@ body: id: ollama-version attributes: label: Ollama Version (if applicable) - description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1) + description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0) validations: required: false @@ -71,7 +72,7 @@ body: id: operating-system attributes: label: Operating System - description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12) + description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13) validations: required: true @@ -79,7 +80,7 @@ body: id: browser attributes: label: Browser (if applicable) - description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0) + description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3) validations: required: false @@ -138,11 +139,11 @@ body: placeholder: | Example (include every detail): - 1. Start with a clean Ubuntu 22.04 install. - 2. Install Docker v24.0.5 and start the service. + 1. Start with a clean Ubuntu 26.04 install. + 2. Install Docker v29.7.1 and start the service. 3. Clone the Open WebUI repo (git clone ...). 4. Use the Docker Compose file without modifications. - 5. Open browser Chrome 115.0 in incognito mode. + 5. Open browser Chrome 151.0 in incognito mode. 6. Go to http://localhost:8080 and log in with user "test@example.com". 7. Set the language to "English" and theme to "Dark". 8. Attempt to connect to Ollama at "http://localhost:11434". diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index d5f3a86132..0972f9cb9a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -36,6 +36,8 @@ body: options: - label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request. required: true + - label: I have checked whether this request is already implemented on the `dev` branch or in the latest source. + required: true - type: checkboxes id: feature-scope diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 957f5a3768..27f795ace7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,30 +1,55 @@ # Pull Request Checklist -### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request. +### Do not open a pull request as the first step. -This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR. +For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues) that explains the problem, why it matters, and what outcome you are looking for. + +For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). Merely opening a discussion is not enough; it needs to be actively discussed. + +If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch. + +Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful. + +We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation. + +Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude. -**Before submitting, make sure you've checked the following:** +**Before submitting, make sure you've checked and filled out the following:** -- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review. +- [ ] **Linked Issue/Discussion:** This PR references an existing, well-described [Issue](https://github.com/open-webui/open-webui/issues) for a real bug or an active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) for a feature request or enhancement — `Closes #___` / `Relates to #___`. +- [ ] **First-time contributor policy:** This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion. - [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.** - [ ] **Description:** A concise description of the changes is provided below. - [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom. - [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs). - [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented. -- [ ] **Testing:** Manual tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable. +- [ ] **Testing:** **Manual** end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable. +- [ ] **User-facing changes:** I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended. - [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately. - [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards. - [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first. @@ -40,9 +65,6 @@ The most impactful way to contribute to Open WebUI is through well-written bug r - **i18n**: Internationalization or localization changes - **perf**: Performance improvements - **refactor**: Code restructuring - - **style**: Formatting changes (whitespace, semicolons, etc.) - - **test**: Test additions or corrections - - **WIP**: Work in progress # Changelog Entry @@ -86,7 +108,7 @@ The most impactful way to contribute to Open WebUI is through well-written bug r ### Screenshots or Videos -- [Attach relevant screenshots or videos demonstrating the changes] +- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.] ### Contributor License Agreement diff --git a/.github/workflows/issue-label.yaml b/.github/workflows/issue-label.yaml index 94f0d82ead..1a138e2f11 100644 --- a/.github/workflows/issue-label.yaml +++ b/.github/workflows/issue-label.yaml @@ -2,7 +2,7 @@ name: Issue Labeler on: issues: - types: [opened] + types: [opened, edited] permissions: issues: write @@ -22,22 +22,118 @@ jobs: return; } + const isEdit = context.payload.action === 'edited'; + const titleWasEdited = Boolean(context.payload.changes?.title); + + if (isEdit && !titleWasEdited) { + return; + } + const title = issue.title ?? ''; const body = issue.body ?? ''; // Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..." - const bugLikeTitle = /^\s*\[?(bug|issue|fix)\]?\s*[:/\-]/i.test(title); + const bugLikeTitle = /^\s*(\[\s*(bug|issue|fix)\b[^\]]*\]|(bug|issue|fix)\s*[:/\-])/i.test(title); // API/CLI-created issues that reproduce the bug report form structure. // Only headings distinctive to the bug form (both are required fields there) — // generic headings like "Expected Behavior" also appear in freeform feature requests. const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body); - if (bugLikeTitle || bugFormBody) { - await github.rest.issues.addLabels({ + if (!bugLikeTitle && !bugFormBody) { + return; + } + + if (isEdit) { + const events = await github.paginate(github.rest.issues.listEvents, { owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, - labels: ['bug'] + per_page: 100 }); + + const bugLabelWasRemoved = events.some( + (event) => event.event === 'unlabeled' && event.label?.name === 'bug' + ); + + if (bugLabelWasRemoved) { + return; + } } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['bug'] + }); + + label-feature-requests: + runs-on: ubuntu-latest + steps: + - name: Add "enhancement" label to unlabeled feature requests + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + + if (issue.labels.some((label) => label.name === 'enhancement')) { + return; + } + + // A human (or the bug form) already classified this as a bug; + // do not stack a second, contradictory classification on it. + if (issue.labels.some((label) => label.name === 'bug')) { + return; + } + + const isEdit = context.payload.action === 'edited'; + const titleWasEdited = Boolean(context.payload.changes?.title); + + if (isEdit && !titleWasEdited) { + return; + } + + const title = issue.title ?? ''; + const body = issue.body ?? ''; + + // Feature requests: "feat: ...", "feature: ...", "feature request: ...", + // "enhancement: ...", "enh: ...", "[Feature Request] ..." — the feature + // request form titles every submission "feat: ", so form submissions are + // covered by the same pattern. + const featureLikeTitle = + /^\s*(\[\s*(feat|feature|enhancement|enh)\b[^\]]*\]|(feat|feature( request)?|enhancement|enh)\s*[:/\-])/i.test( + title + ); + + // API/CLI-created issues that reproduce the feature request form structure. + // Only headings distinctive to that form. + const featureFormBody = /###\s*(Proposed Solution|Alternatives Considered)/i.test(body); + + if (!featureLikeTitle && !featureFormBody) { + return; + } + + if (isEdit) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: 100 + }); + + const enhancementLabelWasRemoved = events.some( + (event) => event.event === 'unlabeled' && event.label?.name === 'enhancement' + ); + + if (enhancementLabelWasRemoved) { + return; + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['enhancement'] + }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e836b11c..b5dbe95b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,322 @@ 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.11.1] - 2026-08-25 + +### Added + +- 🚦 **Human in the loop tool approval.** Where an administrator has turned it on, you can switch a conversation from letting tools run freely to being asked first, so a model that wants to use a tool stops and waits for you to allow or deny it, one call at a time in a saved conversation, by button or by keyboard shortcut, with your choice remembered for this conversation and for future ones, switching back to running freely releasing anything already waiting, and automations, channel replies, and temporary chats unaffected. [Commit](https://github.com/open-webui/open-webui/commit/7d99b2716a0472b2100b3a71825d8eb3fcbbe877), [Commit](https://github.com/open-webui/open-webui/commit/ec36972c2b5a8d48713f1a240b0ed305e535b4cc), [Commit](https://github.com/open-webui/open-webui/commit/653562d660398c32a9a193450bbbee300d7195c3), [Commit](https://github.com/open-webui/open-webui/commit/fa94a5ab2431edba064150651a14dd4992ef29e8), [Commit](https://github.com/open-webui/open-webui/commit/55c202e841e76cd69679206cd0ecb4a65b039ea4), [Commit](https://github.com/open-webui/open-webui/commit/30f82788bc75e6965da3766e3bf43840dd971eed), [Commit](https://github.com/open-webui/open-webui/commit/bbfdbd59f29e246db1d8b5c2401bc7b11aa32c99), [Commit](https://github.com/open-webui/open-webui/commit/7fc5fa1ff3f6cd6f0efcbf847064ca8d21620ba6), [Commit](https://github.com/open-webui/open-webui/commit/3eb65f47151af4fb4ccfaf33d6e77154546e7259), [Commit](https://github.com/open-webui/open-webui/commit/62fc436999ad32e82d1405ac14d1f03e0f0341ec) +- 🙋‍♂️ **Models that can ask you a question.** A new built-in tool lets a model pause and put up to three multiple-choice questions to you before continuing, with room to type your own answer instead, and the question survives a reload in a saved conversation, so you can come back and answer it later rather than losing the conversation. [Commit](https://github.com/open-webui/open-webui/commit/4465f52a3eb521854cf190f91b0ea7cf3fa21830), [Commit](https://github.com/open-webui/open-webui/commit/133549a87ee371577453d897c2db2d8071223f27), [Commit](https://github.com/open-webui/open-webui/commit/b018feb7419e68314378b3cdd8b7b1b389400a98), [Commit](https://github.com/open-webui/open-webui/commit/083e35144152d6a301bed01aa6d898db3871ddcf), [Commit](https://github.com/open-webui/open-webui/commit/256cce505be8ddd4930e8cb2536faea718d3f789), [Commit](https://github.com/open-webui/open-webui/commit/14e4d72d9a21a10196e8e6efb04180cd9104b8e6), [Commit](https://github.com/open-webui/open-webui/commit/57bd08304e45f707768a898de9f50894929008dd), [Commit](https://github.com/open-webui/open-webui/commit/d9014b3483d4c6e8d99e50395afcdc538bcf05cd) +- 🖇 **Agents can now display terminal files directly.** A model can now show a file it made in a terminal directly in its reply, with a preview and a download button, instead of describing a path that led nowhere when clicked, and a new interface setting chooses whether these open in the reply or in the files pane. [Commit](https://github.com/open-webui/open-webui/commit/78f48a21eef330c0b78c33f2b5fc2169b084997c), [Commit](https://github.com/open-webui/open-webui/commit/e623c02acc70c4ee5d7f2eb2f32d9b7f39287663), [Commit](https://github.com/open-webui/open-webui/commit/f64c0c87e8d1bfdbe060ea5e5a3dee24c0323657), [#27650](https://github.com/open-webui/open-webui/issues/27650) +- 📶 **Streaming rebuilt from the ground up.** A reply now streams as small pieces of new text instead of resending the whole message so far with every update, so the data sent over a reply grows with its length rather than with its length squared, which on a server with many people chatting at once means far less processor time spent encoding, passing, and decoding those updates, far less load and memory on the shared cache that carries them between instances, and far less work in your browser, which no longer takes in the whole reply again and redraws the parts of it that have not changed on every update, cutting the data sent and the server work spent appending to a message by up to 1000x on a very long reply, and a reply still in progress is now kept aside on the server, so reopening the conversation after a refresh picks it up where it is rather than showing a blank message, on deployments backed by Redis. [Commit](https://github.com/open-webui/open-webui/commit/a1579a01ff43cacb357269707d36267ad35e01d6), [Commit](https://github.com/open-webui/open-webui/commit/c755ef60c6bd47ea25306bd898d9a6d1bd8e871d), [Commit](https://github.com/open-webui/open-webui/commit/d02b6a21fc02fb073782e25356968cfee3c45c36), [Commit](https://github.com/open-webui/open-webui/commit/3e186abdd91edee9e97e43c9b345714643a84cf5) +- 🪵 **Much faster throughout.** Hundreds of places across the application no longer assemble detailed log text that is switched off and thrown away unread, so sending messages, uploading and indexing files, running searches, signing in, and loading admin pages all get that time back, with the largest gains on busy servers, in long conversations, and on chats that draw from a large knowledge base. [#27834](https://github.com/open-webui/open-webui/pull/27834), [#27837](https://github.com/open-webui/open-webui/pull/27837) +- 🚀 **Faster model list lookups.** Title generation, tag suggestions, autocomplete, and other background steps of a chat turn now fetch the model list in one go, which keeps other people's responses flowing on busy Redis-backed instances with many models. [#27821](https://github.com/open-webui/open-webui/pull/27821) +- 🛰️ **Cheaper log export.** Deployments that export their logs to a telemetry collector, with "ENABLE_OTEL" and "ENABLE_OTEL_LOGS" both set, now prepare each exported line once instead of twice, which matters more than it used to now that log text is only assembled when something will actually read it. [#27840](https://github.com/open-webui/open-webui/pull/27840) +- 📇 **Faster permission checks on large instances.** Working out which groups you belong to is now a direct lookup rather than a scan of every membership on the server, so chats and the admin user list stay quick as an organization grows. [#27822](https://github.com/open-webui/open-webui/pull/27822) +- ⚙️ **Much faster JSON handling.** Saving and opening chats, reading settings, returning results from built-in tools, streaming replies, signing in and signing up, working out your permissions, and reading stored chunk details during knowledge base searches on Valkey and Oracle vector storage are all handled much faster across the application when the "ENABLE_ORJSON" option is turned on. [Commit](https://github.com/open-webui/open-webui/commit/bb0f898b431d5aa45efa7805956657ed9c3dd78d), [#28396](https://github.com/open-webui/open-webui/pull/28396), [#27841](https://github.com/open-webui/open-webui/pull/27841), [#27807](https://github.com/open-webui/open-webui/pull/27807), [#27805](https://github.com/open-webui/open-webui/pull/27805), [#27813](https://github.com/open-webui/open-webui/pull/27813) +- 📤 **Much faster outbound requests.** Conversations and embedding batches sent to Ollama and Anthropic models are packaged for delivery much faster, which is most noticeable in long chats when the "ENABLE_ORJSON" option is turned on. [#27811](https://github.com/open-webui/open-webui/pull/27811), [#27810](https://github.com/open-webui/open-webui/pull/27810) +- 🐍 **Much faster code interpreter output.** Printed output and generated images from code run in chat appear much faster when the "ENABLE_ORJSON" option is turned on. [#27812](https://github.com/open-webui/open-webui/pull/27812) +- 🪶 **Lighter page loads.** Several small requests the interface makes on every page load, along with a few administrative ones, no longer set up database access they never used, which took several times longer than the rest of the request put together. [#28178](https://github.com/open-webui/open-webui/pull/28178) +- ♻️ **One less read per message.** Sending a message no longer loads the whole conversation from the database twice over, which mattered most in long chats where that record is largest. [#28809](https://github.com/open-webui/open-webui/pull/28809) +- 🏁 **Faster skills on large instances.** Opening the skills list, or sending a message that uses one, no longer checks every skill on the instance one at a time, so both are far quicker where many skills exist and most of them are not yours. [#28798](https://github.com/open-webui/open-webui/pull/28798) +- 🩻 **Faster tools on large instances.** Listing or exporting tools no longer checks every tool on the instance one at a time, so the integrations menu and the tools workspace open faster where many exist. [Commit](https://github.com/open-webui/open-webui/commit/4807866a1cf47340f1b5ea76fded95f8114305f9) +- 🧊 **Faster file access checks.** Checking whether you may reach a file no longer walks every workspace model you can see looking for it, so opening a folder of files, downloading one, or retrieving from one is much quicker on instances with many models. [#28802](https://github.com/open-webui/open-webui/pull/28802) +- 🧱 **Faster folder listings.** Listing your folders now works out your group memberships once for the whole listing rather than again for every item in every folder. [#28810](https://github.com/open-webui/open-webui/pull/28810) +- 🧼 **Less work per update in a long chat.** Each update saved while a reply streams no longer re-examines the entire conversation, only the part being added, so the cost of an update stops growing with the length of the chat. [#28820](https://github.com/open-webui/open-webui/pull/28820) +- 🗃 **Cheaper attaching of sources and files to a reply.** Adding a source, file, or embedded item to a reply now reads just that one field rather than rebuilding the whole conversation to find it, which on a two hundred message chat is around 3.1 ms per item down to 0.65 ms, and no longer grows with the length of the conversation. [Commit](https://github.com/open-webui/open-webui/commit/536b9edec00547d5b84ef2e6ea0f929c054c1333), [Commit](https://github.com/open-webui/open-webui/commit/9dff5e93277aa1a2236d702e7e2f55dbb35ea7fa) +- 🚏 **Faster workspace model lookups.** Working out which workspace models you may edit no longer loads every model on the instance and discards most of them, which also speeds up exporting models and the file access checks that relied on it. [#28795](https://github.com/open-webui/open-webui/pull/28795) +- 📮 **Faster handing off a streaming reply.** Passing a reply in progress between instances now writes it once rather than converting it back and forth and scanning it for characters that only matter elsewhere, which on a large non-English conversation took most of the time spent on each write. [#28833](https://github.com/open-webui/open-webui/pull/28833) +- 🥵 **Constant load on an idle instance.** An instance sitting idle no longer works through every chat you have once a second looking for timers that are due, which on a large history kept about a quarter of a processor core busy doing nothing and could exhaust memory until the application was killed. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745) +- 📍 **Sidebar folders fetched once.** Refreshing the sidebar now asks for your folders once rather than three times, on page load and on every action that refreshes it. [#28662](https://github.com/open-webui/open-webui/pull/28662), [#28661](https://github.com/open-webui/open-webui/issues/28661) +- 💤 **Far fewer writes just from being signed in.** Recording that someone is online now writes at most once a minute for each person rather than on every single request, where an open tab alone caused two write transactions a minute before anyone touched anything. [#28177](https://github.com/open-webui/open-webui/pull/28177), [#28165](https://github.com/open-webui/open-webui/issues/28165) +- 🛰 **Less overhead on every request.** The layers each request passes through before it is handled are now one instead of five, which also removes a quarter of that cost from every piece of a streamed reply on instances that set security headers. [Commit](https://github.com/open-webui/open-webui/commit/b96d2b12dae5e953e520bec03f74e9b85b955dc7), [#28171](https://github.com/open-webui/open-webui/issues/28171) +- 🎏 **Turning off compression of live updates.** A new "UVICORN_WS_PER_MESSAGE_DEFLATE" setting stops the server compressing every live update it sends, which costs processor time on each one for almost no saving now that a reply streams as small pieces; compression stays on unless it is turned off. [#28613](https://github.com/open-webui/open-webui/pull/28613) +- 🌡 **Faster chat list and unread counts.** Opening the sidebar, and the unread markers on folders, no longer read through your whole chat history to produce a short list, which on an instance with 15000 chats took 2 to 4 seconds. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745) +- 🥁 **Long replies no longer slow as they grow.** A long reply is no longer re-examined from the beginning for reasoning and code blocks on every piece that arrives, so the work stops growing with the length of the reply, which on a long reply is around 190x less time spent on it. [#28861](https://github.com/open-webui/open-webui/pull/28861) +- 💽 **Faster saving of long chats.** A chat is now written to the database in one go rather than one message at a time, so saving a long conversation is much quicker and puts far less strain on the database, and saving one where nothing has changed writes nothing at all. [#28806](https://github.com/open-webui/open-webui/pull/28806) +- 📦 **Faster loading of shared folders.** Folders shared with you now load in a couple of queries rather than one for each folder and each owner, so the list appears sooner for anyone with many of them. [#28804](https://github.com/open-webui/open-webui/pull/28804) +- ⚡ **Uninterrupted chat during knowledge search.** Responses now keep streaming for everyone on the server while knowledge base searches run, instead of pausing until each search finishes. [#27824](https://github.com/open-webui/open-webui/pull/27824) +- 🔍 **Smarter chat search.** Searching your chats now finds conversations containing all of your words in any order rather than only the exact phrase you typed, with exact matches still listed first, and the preview snippet points at whichever word it found. [Commit](https://github.com/open-webui/open-webui/commit/0800c21c64c64810f24c2ec88cca5e36daebb10e) +- ⌨️ **Model switching from the message box.** Typing "/model" now tells you which model you are on, switching to another by name with "/model" followed by its id, or opening the model picker straight from the slash menu without reaching for the mouse. [Commit](https://github.com/open-webui/open-webui/commit/29eeda9f9abeaedf77176908cb036ace7f175e76), [Commit](https://github.com/open-webui/open-webui/commit/9c7ce154e79a3baea3a8223ab1e5b1371ce80dbb) +- 📎 **Sending while attachments upload.** Sending a message before its files have finished uploading now queues it and sends it automatically once they are ready, instead of refusing with an error, and each queued message shows the progress of its attachments. [Commit](https://github.com/open-webui/open-webui/commit/6c4d0ace163a89aba6e8cd2aa9fef185167ebe0f), [Commit](https://github.com/open-webui/open-webui/commit/c1c07cbe0f847e4dd04e21f2a6c5f12cdc0fc7ee), [#28381](https://github.com/open-webui/open-webui/pull/28381), [#28380](https://github.com/open-webui/open-webui/issues/28380) +- 📖 **Opening a document at the right page.** A model showing you a PDF, Word document, or slide deck from a terminal can now open it at a particular page or slide, so a reply that cites something on page 76 can put that page in front of you. [Commit](https://github.com/open-webui/open-webui/commit/fd8cc2ba4a226ccbf51e114c71f156676eabb2d9), [Commit](https://github.com/open-webui/open-webui/commit/cf4ac9c8db67031627f196fae894764367fba5b4), [Commit](https://github.com/open-webui/open-webui/commit/6cb2449ab70ed6aa5124fcd2b1291b48527af4cd) +- 💼 **Attachments that go straight to a terminal.** A terminal connection can now be set to receive files attached in chat into its own working directory rather than into the conversation, which also means files can be attached while using a model that cannot read them itself. [Commit](https://github.com/open-webui/open-webui/commit/8a42aa53e826d5f25d6443ca95e0173e3cf034cf), [Commit](https://github.com/open-webui/open-webui/commit/d7d935275a77fb185586a5130cd37acf1175ca0a), [Commit](https://github.com/open-webui/open-webui/commit/1b3b9375bb4de0f360782dd2f9d8c7c2903b8baa), [Commit](https://github.com/open-webui/open-webui/commit/d17f06a23501e5c39cf98d9c9642178da25343fb) +- 🔦 **Searching files in the terminal browser.** The file browser now has a search box that finds files by name and by what is inside them, and opening a result takes you to the matching line. [Commit](https://github.com/open-webui/open-webui/commit/7abe11346a1cc309da2c3fcec2f61faaeb47b96a) +- 🌲 **Browsing files as a tree.** The terminal file browser now expands folders in place rather than only navigating into them, remembers what you had open, offers a right-click menu, can show hidden files, sorts by size, expands a folder you hover over while dragging something onto it, and moves a whole selection in one go when you drop it. [Commit](https://github.com/open-webui/open-webui/commit/7dfbdd221ac0f7a839dd049e3e82b6a84c316860), [Commit](https://github.com/open-webui/open-webui/commit/516cf1a9a6154336182475b9df2d333b1579516d) +- 🧰 **Managing models on more servers.** Administrators can now download, load, and unload models on llama.cpp and LM Studio connections from the manage models dialog, remove them on llama.cpp, and start a download straight from the model picker's search box, alongside the Ollama support that was already there. [Commit](https://github.com/open-webui/open-webui/commit/85c3d0ae2fad58ffc2b92b1733a6c8bc7ab471db), [Commit](https://github.com/open-webui/open-webui/commit/8260d527ee97372a207ce9bd9c6dab4909061281), [Commit](https://github.com/open-webui/open-webui/commit/25802c048e6123fa602182949ad2a9f349e1e863), [Commit](https://github.com/open-webui/open-webui/commit/31c1ffd55a018a74c7e53a35fb9f9bbe33774b6f), [#28766](https://github.com/open-webui/open-webui/pull/28766) +- 📢 **Automations that post to a channel.** An automation can now be pointed at a channel instead of a chat, so its scheduled run appears as a message there for everyone to see, chosen from a new destination picker that also covers folders. [Commit](https://github.com/open-webui/open-webui/commit/2649e3305c49cb37101c112ae10ffd8beacb5885) +- 🙋 **Mentioning people in a channel.** Typing an at sign in a channel now lists that channel's own members first, before everyone else on the server, so the people you are likely to mean are at the top. [Commit](https://github.com/open-webui/open-webui/commit/ba885d0026ad3cce70c7cc36d1d6f59775264a12), [Commit](https://github.com/open-webui/open-webui/commit/dcff244f9e87e615ee0a3f345f7f801c5ebe370d), [#28883](https://github.com/open-webui/open-webui/issues/28883) +- 🔗 **Attaching any link.** Pasting a link into a chat or a knowledge base now works out what is behind it, downloading a document or image as a real attachment rather than treating everything as a web page to be read as text. [Commit](https://github.com/open-webui/open-webui/commit/8fbfd14a8b3fe7236883594fd18760076e4de0d5) +- 🔎 **Searching tools and skills in chat.** The integrations menu now has a search box for tools and for skills, so a long list can be narrowed by name instead of scrolled through. [#26709](https://github.com/open-webui/open-webui/issues/26709), [Commit](https://github.com/open-webui/open-webui/commit/954613944b317a78d5929456ae1388543aa052c8), [#28807](https://github.com/open-webui/open-webui/pull/28807), [#28812](https://github.com/open-webui/open-webui/pull/28812) +- 🧭 **More from the message box.** The slash menu now offers settings and, in a new chat, a toggle for temporary chat, alongside the commands that were already there. [Commit](https://github.com/open-webui/open-webui/commit/a5ea732c1e3d0d1f70a7e4b6a20c30c336114784) +- 🚨 **Being told when a file fails to process.** A file that cannot be processed for a knowledge base now raises a notification naming the file and what went wrong, and keeps that reason on the file, instead of quietly being marked as failed. [#27666](https://github.com/open-webui/open-webui/pull/27666), [#6311](https://github.com/open-webui/open-webui/issues/6311), [Commit](https://github.com/open-webui/open-webui/commit/1a376ac17fa0c3f957656a997b6f8ffacb1f6f30) +- 🔬 **Zoom controls on previewed images.** An image opened in the file browser now has zoom in, zoom out, and a reset button showing the current zoom, and pinching, scrolling, and holding a modifier key while scrolling now zoom and pan as they do elsewhere. [Commit](https://github.com/open-webui/open-webui/commit/2befa8f796266e92fa55861bb8eaa81639ff4053), [Commit](https://github.com/open-webui/open-webui/commit/bfb68feea766e8d5408fb6e278be56cbca0c4afe), [Commit](https://github.com/open-webui/open-webui/commit/ec9bf5a64f9f718e472123350ec83bce1b064884), [Commit](https://github.com/open-webui/open-webui/commit/467be93e6d7e4a31358f9c75ee67bfac1200c0a1) +- 🗂️ **Recognisable file icons.** The terminal file browser now marks each file with an icon for its type, so code, images, archives, documents, and configuration files can be told apart at a glance instead of sharing one generic page icon. [Commit](https://github.com/open-webui/open-webui/commit/60feca71a6a77b7d1eef1c172a61624d5fe48150), [Commit](https://github.com/open-webui/open-webui/commit/8d25ad00e2fc5f0a328a2098c2e73fb00f2ca934), [Commit](https://github.com/open-webui/open-webui/commit/c1f914a6268580f7722825390ac8a204743a4520) +- 📽️ **Truer PowerPoint previews.** Slide previews now render tables, charts, connectors, gradients, theme colours, bullets, fonts, and text alignment far closer to the original, and the viewer lets you move between slides with the arrow keys or the scroll wheel while the thumbnail strip follows along. [Commit](https://github.com/open-webui/open-webui/commit/8dd23f74c9a59fdfbdaefb4a403ef79bdd3dfed9), [Commit](https://github.com/open-webui/open-webui/commit/048c06399363baac4046717129f072172ee90805), [Commit](https://github.com/open-webui/open-webui/commit/c93c6d6fc48ee8870eec349cb4c5af88c9435cfb), [Commit](https://github.com/open-webui/open-webui/commit/76583749edb966c0239ce203a84c1ee8d8c7faee), [Commit](https://github.com/open-webui/open-webui/commit/b3a5fd3875dc3c29a11eb5435438e55685cad639), [Commit](https://github.com/open-webui/open-webui/commit/794671a9883d5e067c407067f324ffa25ed38e3f), [Commit](https://github.com/open-webui/open-webui/commit/f96b717566b2084bd5fe70fb622ab66b5a643c0d), [Commit](https://github.com/open-webui/open-webui/commit/b7292890ccea794750651abbcde01cda62551e1b), [Commit](https://github.com/open-webui/open-webui/commit/31d08d592c4ee46a7ce827d6ffdf8e3cf16bf2ac) +- 📄 **Faithful Word document previews.** Word documents now open as proper pages with headers, footers, footnotes, and embedded images intact, and can be zoomed, rather than being flattened into plain formatted text. [Commit](https://github.com/open-webui/open-webui/commit/ff7467b4c593f1e775311c088c63340d0cc1e4a2), [Commit](https://github.com/open-webui/open-webui/commit/060648f939447e2a9de390d05221ca896c50eeed) +- 🗝 **Deleting your API key.** An API key can now be revoked outright from your account settings, where the only way to retire one was to replace it with a new one. [#28874](https://github.com/open-webui/open-webui/issues/28874), [Commit](https://github.com/open-webui/open-webui/commit/b30b11d4c975b8ff9a6c6eb9e39fbb310aed1072) +- 🎛️ **Settings for the task model.** Administrators can now set the generation parameters used for background work such as titles, tags, follow-ups, search queries, and conversation summaries, either from the admin panel or through "TASK_MODEL_PARAMS", instead of those requests always using a fixed token limit that could cut a summary short. [#27604](https://github.com/open-webui/open-webui/issues/27604), [Commit](https://github.com/open-webui/open-webui/commit/f0bfcd40976dfb1e1876f86b39d6a659d894d15e), [Commit](https://github.com/open-webui/open-webui/commit/865c80c1600ecc2e606bef4e90540ce305b4b9de) +- 🎚️ **Default interface settings for everyone.** Administrators can now set system-wide defaults for the interface options in Settings, either from the admin panel or through "DEFAULT_INTERFACE_SETTINGS", with each person's own choices still taking precedence and anything left untouched shown as inherited and kept in step with later changes to the defaults. [Commit](https://github.com/open-webui/open-webui/commit/37f2548155efdd5cd8114ceb9b57c7864de4bef2), [Commit](https://github.com/open-webui/open-webui/commit/13346c5f1621b014e2fcaabd41375d6f7035dd0a), [Commit](https://github.com/open-webui/open-webui/commit/90a0e61cef119154e89d900d6921123b599a0389), [Commit](https://github.com/open-webui/open-webui/commit/eeaf1a1df01525e4b6d4eb73060b32a9a75ebfc6), [Commit](https://github.com/open-webui/open-webui/commit/d4461bd6f39936460a989e8bd50097de604b4b00), [Commit](https://github.com/open-webui/open-webui/commit/b4d3b27caf1587b783a46268c769132eb9211c80), [Commit](https://github.com/open-webui/open-webui/commit/407c40f72cdadcd9f57538d9ce38070662ce42c7), [Commit](https://github.com/open-webui/open-webui/commit/724d2ebbf1bd2fc90f27a2e5bc571d40afc3db4c), [Commit](https://github.com/open-webui/open-webui/commit/1674e5a9ef835696f0963cdbcf477c8b92ce0b8a) +- 🔠 **Interface scaling throughout.** The UI Scale setting and your browser's own text size now resize the whole interface consistently, including the sidebar, menus, dialogs, and file browser, rather than leaving parts of it fixed. [Commit](https://github.com/open-webui/open-webui/commit/30d08a42f8b0c32cc64dccd81203cc760651584a), [Commit](https://github.com/open-webui/open-webui/commit/b5f86e6a433b519e30a5a6f1b0a5bf985939c9a8), [Commit](https://github.com/open-webui/open-webui/commit/ec03e8814403422a6ab3dc62f4239fd7af439361), [Commit](https://github.com/open-webui/open-webui/commit/72a909fd2f3ac8d67554f6c85bc1723934454e2d) +- 🏷️ **Named writing blocks.** When a model wraps a draft such as an email in a writing block, the block is now titled with its subject and shows the recipient beside it, rather than every block reading simply as Writing. [#28280](https://github.com/open-webui/open-webui/pull/28280), [#28198](https://github.com/open-webui/open-webui/issues/28198) +- 🤝 **Files for delegated tasks.** A task handed to a sub-agent can now carry the attachments it needs, so an image or document from your conversation reaches the sub-agent instead of arriving as a file reference it cannot open and may answer about anyway. [Commit](https://github.com/open-webui/open-webui/commit/5ec16e76e6402980b39c26923b8ee26278f0b243), [#28213](https://github.com/open-webui/open-webui/issues/28213) +- 📟 **Terminal availability and scope.** Administrators can now decide for each managed terminal whether it appears in chats and in automations at all, and whether everyone shares a single workspace or each chat or automation gets its own, with per chat terminals waiting until the conversation has been saved. [Commit](https://github.com/open-webui/open-webui/commit/009999f3636b1a3451f8fe55232e6ab132a64e66) +- 🔒 **Read-only files in the terminal browser.** Files and folders you are not allowed to change are now labelled read-only, with uploading, editing, renaming, moving, and deleting turned off for them rather than failing at the moment you try. [Commit](https://github.com/open-webui/open-webui/commit/2dadc5435af77b0638af69200e3af1b9654417b6) +- 🔐 **Terminals that use your own login.** Managed terminals configured for session authentication now authenticate the terminal connection with your own token, where it previously sent no credentials at all. [Commit](https://github.com/open-webui/open-webui/commit/2dadc5435af77b0638af69200e3af1b9654417b6) +- 🎟 **Setting up a tool server that uses OAuth.** Adding one is now easier to get right: the connection dialog can authorize the account from the dialog itself, the check button tests the sign-in details rather than reporting a connection failure that was never going to succeed without them, and it is now labelled for what it does rather than suggesting it verifies the whole connection. [Commit](https://github.com/open-webui/open-webui/commit/f822605b3563c57030aa200492f68572cadcc4da), [#28552](https://github.com/open-webui/open-webui/issues/28552) +- 🪤 **Control over what embedded pages may do.** Two new interface settings decide whether pages shown inside a chat, such as an artifact or an HTML preview, may run scripts and start downloads. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf), [#28924](https://github.com/open-webui/open-webui/issues/28924), [Commit](https://github.com/open-webui/open-webui/commit/842c1f9d677c0d9940cccdd18e34add13f746a68) +- 🗄 **Keeping files removed from a knowledge base.** A new "ENABLE_KNOWLEDGE_FILE_RETENTION" setting keeps the stored file and its search data when a file is taken out of a knowledge base, rather than deleting them. [Commit](https://github.com/open-webui/open-webui/commit/363ad352fec9553469852d111bc0506b896504a6) +- 🧾 **CSV shape in retrieval.** Turning on "ENABLE_RAG_CSV_SUMMARY" adds a short line naming the row count, data row count, column count, and column names of a CSV file to what the model sees, giving it the shape of the table alongside its contents. [Commit](https://github.com/open-webui/open-webui/commit/1b72899f246ba46ab8f7cd6aee2d56ef69217d82) +- 🔭 **OpenSERP in the search settings.** OpenSERP can now be picked as the web search engine in the admin panel, with a field for its address, rather than only being configurable through the environment. [#27594](https://github.com/open-webui/open-webui/pull/27594), [#27592](https://github.com/open-webui/open-webui/issues/27592) +- 🪧 **Profile changes from single sign-on.** A name, email address, or picture updated from an identity provider at sign-in now raises an event naming what changed, and the rest of the session uses the updated record rather than a stale copy. [Commit](https://github.com/open-webui/open-webui/commit/927ce0eae67af1b4a856d3e82fba0601c3b40d02) +- 📯 **Group changes from single sign-on.** Group memberships added or removed when someone signs in through an identity provider, and groups created automatically along the way, now raise the same events as the equivalent change made by an administrator or over directory sync. [#27657](https://github.com/open-webui/open-webui/pull/27657) +- 🔔 **Sign-in and sign-out events for single sign-on.** Signing in through an identity provider now raises the same login event that signing in with a password does, and signing out says which provider the session came from, so a function can set up or tidy up an account in another system when someone arrives or leaves. [#27619](https://github.com/open-webui/open-webui/pull/27619), [#27613](https://github.com/open-webui/open-webui/issues/27613) +- 🪛 **Naming background worker threads.** A new "THREAD_POOL_THREAD_NAME_PREFIX" setting labels the threads that background work runs in, so they can be told apart when reading a profile or a thread dump. [Commit](https://github.com/open-webui/open-webui/commit/4ec6ee14418edd04eaba9e34bd5868453f61df40) +- 📙 **OpenDocument files in a temporary chat.** A text document, spreadsheet, or presentation from an office suite that uses the OpenDocument format now has its text read out in the browser when attached to a temporary chat, where the model was handed the raw archive and answered that it could not read the file. [Commit](https://github.com/open-webui/open-webui/commit/9e7c9360b744c878ae0c38aa1caf3886f27b07ab), [#28906](https://github.com/open-webui/open-webui/discussions/28906) +- 🌍 **Pointing Tavily somewhere else.** A new "TAVILY_API_BASE_URL" setting sends Tavily searches and page fetches to a different address, for instances that reach the internet only through a gateway of their own or that use a compatible service. [Commit](https://github.com/open-webui/open-webui/commit/98ee2bdfd3e90faec6bfe8e7ebf5803159f379e3), [#28701](https://github.com/open-webui/open-webui/issues/28701) +- 🪟 **Honest OAuth settings.** When single sign-on settings come from the environment rather than being saved in the application, the admin panel now shows them as read-only with a note naming the setting that controls this, instead of accepting edits that were silently discarded on the next restart. [#28276](https://github.com/open-webui/open-webui/pull/28276) +- 📏 **Widening the chat controls pane.** The controls pane can now be dragged as wide as you like, where it stopped at a fixed limit regardless of screen size. [Commit](https://github.com/open-webui/open-webui/commit/0fb542b3764cefecf2366607a4685288051d1b46) +- 📱 **Smoother sidebar on mobile.** The sidebar now follows your finger as you swipe it open or closed, responds to a quick flick, dims the page behind it as it moves, and gives every chat row a menu button you can reach without a hover you cannot perform on a touchscreen. [Commit](https://github.com/open-webui/open-webui/commit/b20bcdbba72707e3b0cf2b9a6a5f3168b464ba3c), [Commit](https://github.com/open-webui/open-webui/commit/178ccb30e1253ea727fe5ccbb5cc3ca3832dc70b), [Commit](https://github.com/open-webui/open-webui/commit/943294df9a03d45e2708b330e876967af3463282), [Commit](https://github.com/open-webui/open-webui/commit/d6679082e5c6b0b54ca00a99a88e66a53a30c7d9), [Commit](https://github.com/open-webui/open-webui/commit/d8ae7ed40551362922925d9d6e47ba65d3658cfd), [Commit](https://github.com/open-webui/open-webui/commit/be4afd75452361ead376d9977cf2ca8cb93e55eb), [Commit](https://github.com/open-webui/open-webui/commit/ab41dcc487d1517f7c8c5d0b02a02cdaadedb03d) +- 🚪 **Sidebar that stays put.** Opening and closing the sidebar is now a smooth transition that keeps your chat list loaded, instead of rebuilding the list each time. [Commit](https://github.com/open-webui/open-webui/commit/3c010951db2cc349466658d3b52234cc22dad327), [Commit](https://github.com/open-webui/open-webui/commit/8edab5020eaa5d47b12a572eb632a891aa29440a), [Commit](https://github.com/open-webui/open-webui/commit/3e9b075954b7fc9d492a7ec832550b10b0bf80ed), [Commit](https://github.com/open-webui/open-webui/commit/3793b0c886f57630dc31320d3c0257c933c6eca1), [Commit](https://github.com/open-webui/open-webui/commit/0b4b7ae5ff3c0a17b58e8e85a5fddf190e3bda14) +- 👁️ **Turning off chat previews.** A new setting under Settings and Interface lets you switch off the preview card that appears when you hover a chat in the sidebar, useful for a quieter sidebar, for sharing your screen, or on a slow connection. [#27632](https://github.com/open-webui/open-webui/pull/27632), [#27639](https://github.com/open-webui/open-webui/issues/27639) +- ☑️ **Checkboxes beside their labels.** In the model editor and the admin model defaults, each capability, feature, and tool checkbox now sits directly in front of its own label instead of at the far edge of its column, where it could look like it belonged to the next one, and the label itself can be clicked to toggle it. [#27788](https://github.com/open-webui/open-webui/pull/27788), [#27771](https://github.com/open-webui/open-webui/issues/27771), [Commit](https://github.com/open-webui/open-webui/commit/1b39ff352a2fa3b57bd7815f44c50daf96937a14), [Commit](https://github.com/open-webui/open-webui/commit/0f821398ca9c9ddd8da521b5f9dcf103030202e8) +- ✍️ **Typing cursor while responding.** A blinking cursor now marks where the reply is being written, from the moment you send your message until generation finishes, in place of the previous loading placeholder. [Commit](https://github.com/open-webui/open-webui/commit/cbb3aade2b4e901c22aa9a30530634721c80b078) +- ✒️ **Underlined text.** Underlined text now appears underlined in a reply instead of showing the markup around it, and underlining is kept when you edit in a rich text box rather than being dropped. [Commit](https://github.com/open-webui/open-webui/commit/11db926a7b471e9477595786ef451a916174cc18), [#26904](https://github.com/open-webui/open-webui/issues/26904) +- 📥 **Adding group members from a file.** Administrators can now add many people to a group at once by uploading a CSV of names and email addresses, with a template to download and a message naming any row whose address does not match an account. [Commit](https://github.com/open-webui/open-webui/commit/f3f7659da754b51c216b17b2d6def4d59071d78f) +- 📑 **Apache Tika 4 support.** Administrators extracting document text with Tika can now choose which server version they run, from Admin Settings under Documents or through "TIKA_SERVER_VERSION", where only Tika 3 was understood before. [Commit](https://github.com/open-webui/open-webui/commit/170ad0595d9440113721eb06375a2fc99aefaad1), [#28939](https://github.com/open-webui/open-webui/issues/28939) +- 💓 **Tunable heartbeat for live updates.** A new "WEBSOCKET_HEARTBEAT_INTERVAL" setting controls how often each open tab checks in with the server, where it was fixed at 30 seconds, so a large deployment can cut background traffic that no one asked for. [Commit](https://github.com/open-webui/open-webui/commit/3c1017f6c3ffc7194f074f2cfd2ea5f49943c575), [#28166](https://github.com/open-webui/open-webui/issues/28166) +- ⌛ **Expiring abandoned reply state.** A new "REDIS_RESPONSE_STREAM_TTL" setting expires the saved state of a reply that never finished, so a server killed mid-answer no longer leaves that data behind for good. [Commit](https://github.com/open-webui/open-webui/commit/176fa462128d5298492db29c67080a4e1afc2642) +- 🖨️ **File and image detail parts on API requests.** A request sent to the OpenAI-compatible endpoint carrying an image detail level or a file part in its message content now forwards both to providers that use the Responses API, where they were dropped, while documents attached inside Open WebUI are unaffected because those still go through knowledge retrieval. [Commit](https://github.com/open-webui/open-webui/commit/ca4e07a40b4ff11989a25d84b517d3d0049f93ae) +- 🧺 **Leaner stored document metadata.** Bulky extraction details such as page layouts, tables and detected languages are no longer kept alongside a document in the vector store, and a new "RAG_METADATA_MAX_VALUE_CHARS" setting drops any remaining oversized value, falling back to the configured upload size limit so a document that expands enormously while being read cannot exhaust a server's memory. [Commit](https://github.com/open-webui/open-webui/commit/278e97589e71d119b887d5bca9d6ae32912d1dff), [Commit](https://github.com/open-webui/open-webui/commit/e3a7a64d82ab2dd06c681ce85027367ccc8234d4), [#29025](https://github.com/open-webui/open-webui/pull/29025) +- 💨 **No filter work on installs without filters.** A completed message on an install with no filter functions and no pipeline filters, which is the default, no longer rebuilds the whole conversation and ships it to the browser as an event nothing acts on. [Commit](https://github.com/open-webui/open-webui/commit/28f2965934f5b6fba0e0c38a15b2af6ee790819f) +- 📂 **Opening a file in a knowledge base.** A file listed in a knowledge base can now be opened and read straight from that list, where the name was shown but nothing happened when it was clicked. [Commit](https://github.com/open-webui/open-webui/commit/20f35d157bc7d535091ce6a90b517c23d26486c0), [#28086](https://github.com/open-webui/open-webui/issues/28086) +- 🧵 **Cheaper saving of a reply as it streams.** The resume snapshot taken on every piece of a streamed reply no longer rebuilds the whole answer each time, so the cost of a save stops growing with the length of the reply. [#28821](https://github.com/open-webui/open-webui/pull/28821) +- 📀 **Less repeated work setting up built-in tools.** Every chat request no longer rebuilds a fresh copy of each built-in tool's definition from scratch, which was paid once per tool on every message. [#28860](https://github.com/open-webui/open-webui/pull/28860) +- 🕹️ **Control over what a terminal port preview may reach.** A new interface setting decides whether a previewed port runs with access to same-origin browser APIs, so you can lock a preview out of them on installs where previews serve content you do not fully trust. [Commit](https://github.com/open-webui/open-webui/commit/54d7a223707f03172efbb9e754db6e69709956d0) +- ♿ **Improved UI accessibility.** A closed sidebar is no longer reachable by keyboard or announced by screen readers, the tool call blocks in a response can now be expanded with the keyboard, the buttons that normally appear on hover, such as message actions, file removal, and chat menus, now appear when you reach them with the keyboard as well, whatever you have tabbed to is marked with a clear outline throughout the application, and the rows in the integrations menu now tell a screen reader whether each tool or feature is switched on. [Commit](https://github.com/open-webui/open-webui/commit/48a5696042b414f6511911afa72b3289ad2797b9), [Commit](https://github.com/open-webui/open-webui/commit/bd250a0e2431f8c2c7e2f4a34c5327f21fc77d4b), [Commit](https://github.com/open-webui/open-webui/commit/c8f8fa451a60974dfa4ebaf7cd6163ef30c45fd6), [Commit](https://github.com/open-webui/open-webui/commit/29541cbb52659a8a6ee22d255f14e6d2f168a09b), [Commit](https://github.com/open-webui/open-webui/commit/ac0368b4abe4c880f715b236ee0024826b2e3e7a), [Commit](https://github.com/open-webui/open-webui/commit/c086b80313fe0f5ce60831a0d150f695646c66e4), [#27667](https://github.com/open-webui/open-webui/pull/27667), [#17150](https://github.com/open-webui/open-webui/issues/17150) +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 **Translation updates.** Faroese was added, and translations for Slovenian, Hungarian, Finnish, Korean, Portuguese (Brazil), Catalan, and French were enhanced and expanded. + +### Fixed + +- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section. Some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security) +- 🛂 **Knowledge search reaching past what you may read.** Searching knowledge bases now applies the list of collections you are allowed to open, where that restriction was handed to the vector store and silently discarded, so results could include material from knowledge bases you have no access to. [Commit](https://github.com/open-webui/open-webui/commit/1d6d4e6e6647e1d403438ede7bd9ba20bc4cc8f6) +- 💣 **Documents that unpack far beyond their size.** A Word, Excel, PowerPoint, OpenDocument or EPUB file that expands to far more than it stores is now rejected before it is read, where one could previously be used to exhaust a server's memory. [Commit](https://github.com/open-webui/open-webui/commit/2a0274a0a039dbe0a1ad4d24003b085aae7b896b) +- ✂️ **Long replies cut off partway.** A single oversized piece of a streamed reply, such as a long reasoning trace or a turn carrying many tool calls, no longer ends the answer early with a misleading error about byte counts, which affected every default installation. [#28114](https://github.com/open-webui/open-webui/pull/28114), [#25664](https://github.com/open-webui/open-webui/issues/25664) +- 🚧 **Web address checks that could be skipped.** The fetchable-address test and the operator's web fetch filter list now run on every outgoing request, where a proxied or already-open connection could bypass them and a filter entry written as an address range silently matched nothing at all. [#27823](https://github.com/open-webui/open-webui/pull/27823) +- 🧑‍💻 **Code execution reachable through a tag in a reply.** On installs using native function calling, the older path that runs code found inside a tag in the model's reply is no longer active alongside the built-in tool, so code execution happens only through an explicit tool call. [#29024](https://github.com/open-webui/open-webui/pull/29024) +- ⌚ **Recurrence rules that could tie up the server.** How often an automation repeats is now taken from the rule the scheduler actually parsed rather than from the text of the rule, so a crafted rule can no longer disagree with what gets scheduled and walk the server through an unbounded run of occurrences, and a rule carrying a time zone on its start date now schedules instead of erroring. [Commit](https://github.com/open-webui/open-webui/commit/067114c28038b77045a3a1983e2e2719b1a32ad8) +- 🗝️ **Changing a password now ends other sessions.** Changing your password, or an administrator resetting it for you, now stops every device that was already signed in, where they had stayed signed in on the old password until their session expired on its own, up to four weeks by default; the device making the change is signed out too and asked to sign in again, and this requires Redis, without which nothing can be revoked and a warning is now logged saying so. [#28725](https://github.com/open-webui/open-webui/pull/28725), [#28647](https://github.com/open-webui/open-webui/discussions/28647) +- 🧬 **Workspace models shadowing a real one.** Someone without administrator rights can no longer create, import, or edit a workspace model so that it takes over the identity of a model served by a connected provider, where doing so would have changed what everyone else got when they picked that model. [Commit](https://github.com/open-webui/open-webui/commit/ea55d38793014a4e3cd5a4046816fe22e69e9739) +- 🌳 **Folders disappearing when moved into themselves.** Moving a folder inside one of its own subfolders is now refused, where it was accepted and made that folder and everything in it vanish from the sidebar with no way to bring it back, while leaving the server walking the loop endlessly and querying the database as it went, which could exhaust a worker and its memory; any folder already in that state is returned to the top level. [#28748](https://github.com/open-webui/open-webui/pull/28748) +- 🧨 **Searching a knowledge base with a costly pattern.** A search pattern written so that it expands enormously before it even runs is now refused, where it could tie up the server; ordinary patterns are unaffected. [Commit](https://github.com/open-webui/open-webui/commit/d5b66533e7829654f6fb343abbaaeab988dbfde8), [#28284](https://github.com/open-webui/open-webui/pull/28284) +- 💧 **Attaching a very large file from a link.** A file fetched from a link is now written to disk as it arrives and stops at the configured size limit, where the whole thing was held in memory first with no limit applied, so a large enough file could exhaust the server; a download that fails partway no longer leaves the partial file behind. [#28945](https://github.com/open-webui/open-webui/pull/28945) +- ⛓ **Deleting one knowledge base removing a shared connection.** Deleting an external knowledge base now leaves its connection in place while other knowledge bases still use it, and only an administrator removing the last one clears it, where any user deleting theirs took the connection away from everyone. [#28113](https://github.com/open-webui/open-webui/pull/28113) +- 📡 **Intermittent connection failures.** Requests to model providers and to services on the same network no longer fail intermittently with name lookup errors, often surfacing as a misleading model not found message, because addresses are resolved through the system again by default, with the faster resolver still available through "AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER". [#28242](https://github.com/open-webui/open-webui/pull/28242), [#28013](https://github.com/open-webui/open-webui/issues/28013), [#28215](https://github.com/open-webui/open-webui/issues/28215) +- 🗯️ **Losing the conversation with memory on.** With the memory tool enabled, the model can see the earlier messages in your conversation again, instead of answering the second message as though the first had never been sent. [#28400](https://github.com/open-webui/open-webui/issues/28400) +- 👻 **Vanishing responses.** Replies from Responses-API providers that report an empty output at the end of a stream no longer disappear the moment generation finishes, leaving an empty message in their place. [#27800](https://github.com/open-webui/open-webui/pull/27800), [#27789](https://github.com/open-webui/open-webui/discussions/27789) +- 📥 **Queued messages disappearing.** Messages waiting to be sent are put back in the queue if sending them fails, rather than vanishing without being sent. [Commit](https://github.com/open-webui/open-webui/commit/f79b443c226098564386d0a31708e71ec0146158) +- 🧵 **Replies cut short mid-stream.** A reply no longer breaks off part way through when a provider sends the pieces of its response in an unexpected order, which had left the answer truncated and skipped the filters that run once a message finishes. [#28312](https://github.com/open-webui/open-webui/pull/28312) +- 🧷 **Replies not carried into the next turn.** With providers that skip parts of the streaming sequence, the finished reply is now taken from the completed message, so it stays available as context for your next question and the citations that arrived with it are no longer dropped. [#28310](https://github.com/open-webui/open-webui/pull/28310) +- 🌊 **Replies arriving in oversized pieces.** Very large streamed pieces no longer break the response on default settings, where the reader that splits them safely only ran when a chunk size limit was configured. [Commit](https://github.com/open-webui/open-webui/commit/a33fa05adc6def8f3d098539a4786dc9c7bf61d2) +- 🩹 **Signing in after a long-delayed upgrade.** Accounts on instances that were upgraded from a version older than 0.6.41 to 0.9.6 or newer can sign in again, where an upgrade step had written their single sign-on identity in a form the application could not read afterwards, and a repair step corrects the affected accounts on startup. [#28107](https://github.com/open-webui/open-webui/pull/28107), [#28101](https://github.com/open-webui/open-webui/issues/28101), [Commit](https://github.com/open-webui/open-webui/commit/bd8378f643dd8a56366ad9d1843b7d40f67cf1f9) +- 🔑 **Signing in with some identity providers.** Logging in through a provider that adds its own vendor-specific information to the header of the sign-in token now completes, rather than failing at the final step with a message claiming the email or password was wrong. [#28065](https://github.com/open-webui/open-webui/pull/28065), [#28062](https://github.com/open-webui/open-webui/issues/28062) +- 🔌 **Role changes taking effect at once.** Changing someone's role now ends their live sessions no matter how the change was made, whether by a directory sync, an identity provider, a trusted header, or deleting the account, so permissions from their old role cannot linger, and their browser reconnects on its own. [Commit](https://github.com/open-webui/open-webui/commit/ce3c175e260709f359d7e6cbb3132f0572098b95) +- 🛑 **Memory permission being respected.** Taking away someone's memory permission now also stops their stored memories being added to the context of their conversations, which one path had continued doing regardless. [#27668](https://github.com/open-webui/open-webui/pull/27668) +- 🔍 **Listing a single connection's models.** Asking for the models or version of one particular connection is now restricted to administrators, and a request naming a specific backend is checked against the models that backend actually serves even where the access control bypass is turned on. [Commit](https://github.com/open-webui/open-webui/commit/16f118d77ad9d68c64116f94551ffb14bf8b2abd) +- ⚖️ **Sharing defaults matching what was configured.** On instances upgraded from older versions, public sharing of tools and notes no longer shows as switched on in the admin panel, and saving any unrelated permission no longer grants everyone a capability that was never enabled. [#27716](https://github.com/open-webui/open-webui/pull/27716), [#27715](https://github.com/open-webui/open-webui/issues/27715) +- 🗂️ **Folder permissions when starting a chat.** Starting a conversation filed into a folder now checks that you are allowed to write to that folder, a check the message sending path had been skipping, and every place a chat can be filed now treats ownership, shared access, and unknown folders the same way. [#28366](https://github.com/open-webui/open-webui/pull/28366) +- 🪧 **Clearer attachment failures.** A link that cannot be read now says so and names the link, and a YouTube video whose transcript is refused explains why and points at the proxy setting that exists for it, instead of both being reported as a knowledge base error. [#28362](https://github.com/open-webui/open-webui/pull/28362), [#28361](https://github.com/open-webui/open-webui/issues/28361) +- 🔎 **Chat search finding recent messages.** Searching your chats now looks inside the messages of current conversations on default installations, where it had only been reading an older storage format and missing their content entirely. [Commit](https://github.com/open-webui/open-webui/commit/0800c21c64c64810f24c2ec88cca5e36daebb10e) +- 🧭 **Your place in a compacted chat.** Opening a conversation whose history has been compacted now takes you to its most recent message instead of leaving you parked on the summary, and updates to an existing message no longer move your place in the conversation. [Commit](https://github.com/open-webui/open-webui/commit/5caa91a49304148696641d1cd39e43184ba8d748) +- 🖥️ **Chats with a personal terminal.** Sending a message with a terminal you added yourself under Settings selected no longer fails with a terminal unavailable error, which had blocked those chats since 0.11.0. [#27621](https://github.com/open-webui/open-webui/issues/27621), [Commit](https://github.com/open-webui/open-webui/commit/5b333d75c6adea4d8bde96439974d6e9c83d8198), [Commit](https://github.com/open-webui/open-webui/commit/3becec6ccfc7dc457270d4e9f0d8c957e8160ddc) +- 📆 **Default date for new events.** Creating a calendar event now starts on today's date rather than tomorrow's when you open the form in the evening, or yesterday's when you open it early in the morning. [#27779](https://github.com/open-webui/open-webui/pull/27779), [#27778](https://github.com/open-webui/open-webui/issues/27778) +- 🗓️ **Recurring event times.** Repeating calendar events now show at the time you set them for instead of being worked out in the server's time zone and shifted by the gap between the two. [#27774](https://github.com/open-webui/open-webui/issues/27774), [Commit](https://github.com/open-webui/open-webui/commit/d721b0d19621d01e35505105a778ce5681b2ffba) +- 🧩 **Chats during model list refreshes.** On direct connections, background work such as title and tag generation no longer fails or runs against a mix of old and new model entries while the model list is being refreshed. [#27821](https://github.com/open-webui/open-webui/pull/27821) +- 🎛️ **Chat Controls staying put.** Hovering a chat in the sidebar whose preview contains an artifact no longer forces the Chat Controls pane open and fills it with that artifact, over the chat you currently have open. [#27773](https://github.com/open-webui/open-webui/pull/27773), [#27772](https://github.com/open-webui/open-webui/issues/27772) +- 📨 **Reliable streaming with unusual characters.** Responses containing any of three rare invisible line break characters no longer arrive split or broken when the "ENABLE_ORJSON" option is turned on. [#27819](https://github.com/open-webui/open-webui/pull/27819) +- 🧮 **JSON options honoured again.** Options passed to the shared JSON helper are no longer silently ignored when the "ENABLE_ORJSON" option is turned on, falling back to the standard encoder that supports them. [Commit](https://github.com/open-webui/open-webui/commit/78ed5a0235c4de67828ba4b0d6147035067cec2c) +- 🚫 **Duplicate models in lists.** Adding a model that is already on a connection's allowed list is now rejected instead of quietly adding it a second time, the arena picker no longer offers models you have already chosen, and existing duplicates are cleaned up the next time the list is saved. [#28251](https://github.com/open-webui/open-webui/pull/28251), [#28249](https://github.com/open-webui/open-webui/issues/28249) +- 📁 **Dragging chats into shared folders.** A shared folder you can write to now highlights and accepts a dropped chat, while one you only have read access to no longer offers itself as a drop target for an action that could only fail. [Commit](https://github.com/open-webui/open-webui/commit/e4dd6c4bf14c7ea54c1effa6c40ad9e95185c413), [#28261](https://github.com/open-webui/open-webui/issues/28261) +- ⏰ **Listing automations through chat.** Asking a model to list your automations without naming a folder now returns every automation you have, instead of only the ones that sit outside a folder. [Commit](https://github.com/open-webui/open-webui/commit/f8ac75d188b7e07906aa1d5e648e3fc25a78ef2b) +- 🧠 **Faster follow-ups with memory enabled.** The memories handed to the model now appear in a stable order from one message to the next, so servers that reuse their work between turns no longer reprocess the whole conversation each time you reply. [#28292](https://github.com/open-webui/open-webui/issues/28292), [Commit](https://github.com/open-webui/open-webui/commit/ff74bfa6a117c6f03097034c74c2f9bdfb824203), [Commit](https://github.com/open-webui/open-webui/commit/d22bb6703f244e6769ac9b0f2e41d784ad6cf535) +- 🎯 **Custom model parameters combining.** Setting a custom parameter on a model no longer silently discards every custom parameter defined in the global defaults, and a value sent directly in an API request is no longer overwritten by the model's saved settings. [Commit](https://github.com/open-webui/open-webui/commit/11739a2de8eddf7ef26378356aff8a1bcab4a349), [#28241](https://github.com/open-webui/open-webui/issues/28241) +- 👥 **Sharing with people already added.** The access picker no longer offers people and groups that already have access, and the Users heading no longer appears above an empty list. [Commit](https://github.com/open-webui/open-webui/commit/385d08bea5899bcb160391db466662b0a0a6f801), [#28253](https://github.com/open-webui/open-webui/issues/28253) +- 🔧 **Full tool parameter descriptions.** A tool whose parameter description runs over several lines now passes the whole description to the model instead of only its first line. [Commit](https://github.com/open-webui/open-webui/commit/b606e13da3753027ead0e92e35e1399b60c96c8e) +- 📝 **Notes saved in an unexpected shape.** A note whose content was stored as structured data rather than text no longer breaks the notes page for everything else, and opens with that content shown as a formatted code block. [Commit](https://github.com/open-webui/open-webui/commit/8d1c205d8e7335ef7c292afb125a7270976f327b), [#28222](https://github.com/open-webui/open-webui/issues/28222) +- 💬 **Direct messages after an account is deleted.** A direct message conversation no longer counts a deleted account among its members, and opening a direct message with someone finds the existing conversation instead of starting a second one alongside it. [Commit](https://github.com/open-webui/open-webui/commit/a41faa3c226b20e7db7508c49c7d1d015ac9e973), [#28257](https://github.com/open-webui/open-webui/issues/28257) +- 🫥 **Deactivating a model.** Turning a model off no longer removes the wrong entry from the model list, or fails the list outright and leaves the model picker empty for everyone until the model is turned back on. [Commit](https://github.com/open-webui/open-webui/commit/5cecb7dbfad3994228ee53d19232650400462332), [#28202](https://github.com/open-webui/open-webui/issues/28202) +- 💭 **Readable errors on chat actions.** When moving, renaming, or otherwise changing a chat fails, the message explaining why now appears in place of an unhelpful object placeholder. [#28260](https://github.com/open-webui/open-webui/pull/28260), [#28259](https://github.com/open-webui/open-webui/issues/28259) +- 🪪 **Authorship in shared chats.** A chat shared with you now shows the name and picture of whoever wrote it, in the message list and in the overview panel, rather than crediting the messages to you. [#28274](https://github.com/open-webui/open-webui/pull/28274), [#28273](https://github.com/open-webui/open-webui/issues/28273) +- 🖇️ **Adding terminals over plain connections.** Saving a terminal connection now works when the interface is served without HTTPS, where the dialog would sit there doing nothing because the browser withholds the tool used to generate its identifier. [Commit](https://github.com/open-webui/open-webui/commit/2a45fa04cb1d07b258e0b880ab0c02577f0dc035), [#28148](https://github.com/open-webui/open-webui/issues/28148) +- 🗃️ **openGauss vector storage.** Deployments using openGauss for vector storage no longer fail the moment they touch it. [#27838](https://github.com/open-webui/open-webui/pull/27838) +- 🎚️ **ColBERT reranker startup.** Loading a ColBERT reranker now names the model in the log rather than printing a logging error and a traceback in its place. [#27838](https://github.com/open-webui/open-webui/pull/27838) +- 🖼️ **Images that no longer exist.** A message whose image file has been deleted now shows a small unavailable placeholder that cannot be opened, rather than a broken image that spilled the whole reply text into the picture frame and still opened full screen. [#27730](https://github.com/open-webui/open-webui/pull/27730), [#27728](https://github.com/open-webui/open-webui/issues/27728), [Commit](https://github.com/open-webui/open-webui/commit/f8c5fda283e6206d92e81ae3b83ffe5d7a144b03) +- 🛂 **Connecting external accounts.** Authorizing a tool's external account now completes only for the person who started it, rather than for whoever happens to return with the authorization, and signing out clears the session it relies on. [Commit](https://github.com/open-webui/open-webui/commit/c2107e5bb3689a69c170ca526925f4ed84bd00f5) +- 🪟 **Starting up on Windows.** The Windows start script now creates the secret key it needs on a fresh installation, instead of printing a run of file not found messages and then refusing to start, and it copes with an installation path that contains spaces. [#28061](https://github.com/open-webui/open-webui/pull/28061), [#28060](https://github.com/open-webui/open-webui/issues/28060) +- 🕸️ **Overlapping branches in the overview.** Branch nodes in a chat's overview keep a clear gap between them when the interface is scaled up, rather than sitting on top of one another. [#27995](https://github.com/open-webui/open-webui/pull/27995), [#27994](https://github.com/open-webui/open-webui/issues/27994) +- 🗑️ **Delete offered only when allowed.** The chat deletion controls in the sidebar, the chat menu, search, archived chats, and data controls no longer appear for people whose permissions do not allow deleting, where using them produced an access denied error. [#27714](https://github.com/open-webui/open-webui/pull/27714), [#27713](https://github.com/open-webui/open-webui/issues/27713) +- 🍴 **Fork offered only when allowed.** The fork action and the fork command no longer appear for people whose chat import permission is turned off, where using them produced an access denied error. [#27711](https://github.com/open-webui/open-webui/pull/27711), [#27692](https://github.com/open-webui/open-webui/issues/27692) +- ↕️ **Expand button in the message box.** The button that enlarges the message box no longer sits on top of a tagged model's dismiss button or the first attached file, and stays reachable in long prompts. [#27676](https://github.com/open-webui/open-webui/pull/27676), [#26736](https://github.com/open-webui/open-webui/issues/26736) +- 🔆 **Regenerate in high contrast mode.** With high contrast mode on, the regenerate button now stays visible on earlier replies instead of appearing only when you hover over them. [#27644](https://github.com/open-webui/open-webui/pull/27644), [#27638](https://github.com/open-webui/open-webui/issues/27638) +- ✂️ **Clipped icons and avatars.** The terminal icon beside the message box and the profile picture in account settings are no longer shaved flat along their left edge. [#27691](https://github.com/open-webui/open-webui/pull/27691), [#27690](https://github.com/open-webui/open-webui/issues/27690) +- 🪄 **Merged responses after a reload.** Merging the answers from several models now works on a conversation you have reopened, instead of the merging model reporting that the other responses were empty. [#27673](https://github.com/open-webui/open-webui/pull/27673), [#26962](https://github.com/open-webui/open-webui/issues/26962) +- 🔢 **Token counts for background chats.** Conversations started by automations, timers, sub-agents, and channels now report their token usage like any other chat, rather than arriving without it even when the model is set up to provide it. [#27661](https://github.com/open-webui/open-webui/pull/27661), [#27653](https://github.com/open-webui/open-webui/issues/27653) +- 📐 **Settings on tall screens.** The settings window now grows with the height of your display instead of stopping short and making you scroll inside it while space sits unused above and below. [#27615](https://github.com/open-webui/open-webui/pull/27615), [#27614](https://github.com/open-webui/open-webui/issues/27614) +- 🖱️ **Sections opening by accident.** Folders, collapsible sections, and tool call blocks now open and close only when you click them, rather than also reacting when you release the mouse over them after dragging or selecting text. [Commit](https://github.com/open-webui/open-webui/commit/bd250a0e2431f8c2c7e2f4a34c5327f21fc77d4b) +- 🔁 **Rebuilding knowledge base vectors.** Rebuilding the vectors for a knowledge base now also rebuilds them for each file it contains, so attaching a single file afterwards finds its content instead of quietly returning nothing and letting the model answer from thin air. [#28106](https://github.com/open-webui/open-webui/issues/28106), [Commit](https://github.com/open-webui/open-webui/commit/2a6e671f548970c8223692024e630b9936e9fa7c), [Commit](https://github.com/open-webui/open-webui/commit/89922cc9d585e10b026693681b44afdc4b874588) +- 📌 **Attaching a chat shared with you.** Attaching a conversation that was shared with you, directly or through a shared folder, now brings its content along instead of quietly attaching nothing. [Commit](https://github.com/open-webui/open-webui/commit/5cd9a395344e882b4384bec0899f503ad540aecc) +- 🧲 **The page staying still when typing.** Returning focus to the message box no longer scrolls the conversation, so switching chats, running a command, or picking something from a menu leaves your place on screen alone. [Commit](https://github.com/open-webui/open-webui/commit/9122c24ea2d506aad50384ca8aba1616d1bed626) +- 📷 **Round profile pictures on narrow screens.** Profile pictures in the admin user list and other lists no longer squash into ovals of differing widths when the window is narrow. [#28000](https://github.com/open-webui/open-webui/pull/28000), [#27999](https://github.com/open-webui/open-webui/issues/27999) +- 🎙️ **Voice mode in the notes editor.** The voice mode button is no longer offered in the chat embedded in a note, where it does not apply. [Commit](https://github.com/open-webui/open-webui/commit/9122c24ea2d506aad50384ca8aba1616d1bed626), [Commit](https://github.com/open-webui/open-webui/commit/e963d36e393aead56b941e99d4167fb0dc9d2d7a) +- ⏳ **Analytics stuck loading.** Choosing a custom date range in analytics without picking dates yet no longer leaves the tab spinning forever, including after leaving and coming back to it. [#28125](https://github.com/open-webui/open-webui/issues/28125), [Commit](https://github.com/open-webui/open-webui/commit/629cdcb5303958c5b99ee437030ab80cc418bc2e) +- 🧑‍🤝‍🧑 **Owner avatars on shared chats.** The picture beside a chat someone shared with you now loads, and falls back to the default image if it cannot, rather than leaving a blank gap when the interface and the server are on different addresses. [#28272](https://github.com/open-webui/open-webui/pull/28272), [#28271](https://github.com/open-webui/open-webui/issues/28271) +- 🎨 **Image generation and web search staying switched off.** Turning either off now takes effect at once on every path: sessions opened beforehand can no longer produce images or run searches, an image request no longer reaches the provider on a model using the older tool-calling method, the entry disappears from the integrations menu right away, and an active marker beside the message box no longer lingers after its feature is withdrawn. [#27759](https://github.com/open-webui/open-webui/pull/27759), [#27758](https://github.com/open-webui/open-webui/issues/27758), [#26842](https://github.com/open-webui/open-webui/issues/26842), [#27669](https://github.com/open-webui/open-webui/pull/27669) +- 📰 **Attached pages reaching the model.** The text pulled from an attached web page or YouTube video now actually reaches the model, rather than arriving empty so the reply had nothing to work from, and opening the source to check no longer fails. [#28378](https://github.com/open-webui/open-webui/issues/28378), [Commit](https://github.com/open-webui/open-webui/commit/9c21d4ed3ba9ba8e53def7e1a1366b74834cb42d) +- 🌐 **Tavily page fetching.** Reading a web page with Tavily selected as the loader works again, having failed on every attempt since 0.10.0. [#27636](https://github.com/open-webui/open-webui/pull/27636), [#27602](https://github.com/open-webui/open-webui/issues/27602) +- 🗒️ **Reply box in threads.** The reply box in a channel thread now stays at the bottom of the panel while you scroll back through the replies, instead of scrolling out of sight with them. [#27768](https://github.com/open-webui/open-webui/pull/27768), [#27767](https://github.com/open-webui/open-webui/issues/27767) +- 🎹 **Model picker shortcut.** The keyboard shortcut for opening the model picker works again, and a link to a chat naming a model you do not have still opens the picker with that name filled in. [Commit](https://github.com/open-webui/open-webui/commit/e1acd7e7ca5085e03babbea16b4770e51c38886e) +- ⌨️ **Reaching the download options by keyboard.** In the model picker, arrowing past the last result now moves through the options to fetch that model from each server that can supply it, so they can be chosen with the keyboard instead of only by clicking. [Commit](https://github.com/open-webui/open-webui/commit/4e03d89414a7be1203579f45225ca64f1221205f), [Commit](https://github.com/open-webui/open-webui/commit/25802c048e6123fa602182949ad2a9f349e1e863) +- 📂 **Opening a folder in the sidebar.** Selecting a folder now refreshes just that folder's chats rather than rebuilding the whole folder tree, and a folder that is empty or still loading says so instead of showing nothing. [Commit](https://github.com/open-webui/open-webui/commit/2e939874904e1ae54d02b8bc6d745931a8e1f673), [Commit](https://github.com/open-webui/open-webui/commit/86b7bf1f7e11109816b0d16ada708c637b50c520) +- ⏲️ **Changing an automation through chat.** Asking a model to change one thing about an automation no longer moves it out of its folder or drops its model when the model fills those fields in blank instead of omitting them. [Commit](https://github.com/open-webui/open-webui/commit/90bb94abf9a8c6fbb87c72952295ae39d2ad98e5) +- 🔂 **Automations that run a set number of times.** An automation asked to run a limited number of times is now rejected unless it says when to start counting from, rather than being accepted and then running indefinitely. [#27781](https://github.com/open-webui/open-webui/pull/27781), [#27780](https://github.com/open-webui/open-webui/issues/27780) +- 📅 **Editing a calendar event through chat.** Asking a model to change one thing about an event, such as its title, no longer fails or wipes the details you did not mention. [#27777](https://github.com/open-webui/open-webui/pull/27777), [#27776](https://github.com/open-webui/open-webui/issues/27776) +- 🧹 **Session cleanup on multi-instance setups.** The instance doing the periodic session cleanup now keeps its claim on that job alive between passes, so an idle deployment stops logging a renewal warning every two minutes and the claim no longer lapses for half of every cycle. [Commit](https://github.com/open-webui/open-webui/commit/939bcdb79e3dcad2278f1e7f4f517a3f2f36f3ec), [#27762](https://github.com/open-webui/open-webui/issues/27762) +- 🏠 **Starting folder in the file browser.** Reopening the file browser now keeps the folder you were in, instead of the breadcrumb losing its starting point and jumping you elsewhere. [Commit](https://github.com/open-webui/open-webui/commit/52c5e3b20d3cd8c3a15db50e2cc75bbd6e82068e) +- 🩺 **Repairing default model settings.** Instances whose stored default and pinned model settings had been written in the wrong shape are corrected on startup, so those defaults take effect again. [Commit](https://github.com/open-webui/open-webui/commit/5c05608e3ac0aefce8c1eed18c3e205c61f6b6fe) +- 🧽 **Cleaner conversation history for the model.** Internal bookkeeping attached to your messages, such as attachment records and token counts, is no longer sent to the model along with the conversation. [Commit](https://github.com/open-webui/open-webui/commit/a32a17965ca36f730ebf5ff53a2a8e17082b61a6) +- 🙈 **Needless request from the model picker.** Opening the model picker as a non-administrator no longer fires a request to an administrator-only settings endpoint that was always refused. [Commit](https://github.com/open-webui/open-webui/commit/b4738d1a2e6af1ce20adb322fc6d2aa2c9407901) +- ⏹️ **Stopping a reply that is waiting.** The stop button now ends a reply that is sitting waiting for you, such as one paused on a tool approval, rather than leaving the conversation stuck part way through. [Commit](https://github.com/open-webui/open-webui/commit/f7767d6be774720e16265c6d016bf6a24a1a8759) +- 🏷️ **Folder names with unusual characters.** Naming a folder is no longer refused because another folder's name happens to be similar, and a name ending in a backslash no longer fails outright on PostgreSQL, because names are now compared exactly rather than treated as a search pattern. [#28695](https://github.com/open-webui/open-webui/pull/28695), [#28694](https://github.com/open-webui/open-webui/issues/28694) +- 🤔 **Reasoning carried back to Ollama.** A model's earlier thinking is now passed back to Ollama in its own native field rather than pasted into the message as tagged text, so reasoning models keep their train of thought across turns. [Commit](https://github.com/open-webui/open-webui/commit/3258330729942b533dc5fe141e876cee7e5eb40d) +- 📎 **Default pinned models taking effect.** Changing the default pinned models now reaches people who have never chosen their own, where simply having opened the interface once was enough to freeze the list they first saw, and reordering a pin no longer moves the wrong one or reopens the sidebar section afterwards. [#28069](https://github.com/open-webui/open-webui/pull/28069), [#28067](https://github.com/open-webui/open-webui/discussions/28067) +- ✏️ **Editing other people's channel messages.** Asking a model to work on a message in a channel now only applies to your own messages, where write access to the channel had been enough to reach anyone's. [#28631](https://github.com/open-webui/open-webui/pull/28631) +- 🍪 **Signed-in tool servers.** A tool server that relies on your session now receives the credentials belonging to its own connection, rather than whichever were most recently prepared. [#28630](https://github.com/open-webui/open-webui/pull/28630) +- 🏗️ **Editing a folder from its page.** Renaming a folder, changing its icon, creating a subfolder, or deleting it from the folder's own page now updates the sidebar straight away, instead of leaving the old name and icon there, and the new subfolder missing, until a reload. [Commit](https://github.com/open-webui/open-webui/commit/a40f6f2860b49b4c7f11e369f11bd46e9451eabf), [Commit](https://github.com/open-webui/open-webui/commit/736e38338ede1fca5af0829e35585a72f8517d32), [#28692](https://github.com/open-webui/open-webui/pull/28692), [#28690](https://github.com/open-webui/open-webui/issues/28690) +- 📣 **Long channel names in the sidebar.** A channel with a long name no longer squeezes its own menu button out of the row. [Commit](https://github.com/open-webui/open-webui/commit/b5da50f3df51786972010c75c7736d5f95362e58), [#28671](https://github.com/open-webui/open-webui/pull/28671), [#28670](https://github.com/open-webui/open-webui/issues/28670) +- 📬 **Mark as unread in chat search.** Marking a chat unread from the search dialog now works and updates the sidebar, where the menu entry looked normal but did nothing at all. [#28136](https://github.com/open-webui/open-webui/pull/28136), [#28135](https://github.com/open-webui/open-webui/issues/28135) +- ⬆️ **Scroll to top on the first click.** In a long chat where older messages had not been loaded yet, one click of scroll to top now reaches the first message instead of stopping short and needing a second. [#28659](https://github.com/open-webui/open-webui/pull/28659), [#28658](https://github.com/open-webui/open-webui/issues/28658) +- 🔘 **Double bullets in the release notes.** Each entry in the what's new dialog shows a single bullet again, rather than two sitting at different heights. [#28676](https://github.com/open-webui/open-webui/pull/28676), [#28675](https://github.com/open-webui/open-webui/issues/28675) +- 📚 **Knowledge search and shared files in chat.** A model searching your knowledge bases or reading a file shared with you through a group now works, where it had failed since 0.11.0 and quietly answered as though the knowledge were empty, affecting instances that forward user details to their embedding service and, for shared files, every instance regardless of settings. [#27642](https://github.com/open-webui/open-webui/pull/27642), [#27641](https://github.com/open-webui/open-webui/issues/27641) +- 🔖 **Skill identifiers that cannot be reached.** Creating a skill whose identifier contains a character that is not allowed in a web address is now refused outright, rather than accepted and then permanently impossible to open, edit, turn off, delete, or recreate. [#27660](https://github.com/open-webui/open-webui/pull/27660), [#27655](https://github.com/open-webui/open-webui/issues/27655) +- 🔤 **Model names on connections with a prefix.** A connection that adds a prefix to its model names now strips it before sending a request through the responses endpoint, where the prefixed name was passed on and rejected as unknown. [#28575](https://github.com/open-webui/open-webui/pull/28575), [#28574](https://github.com/open-webui/open-webui/issues/28574) +- 🔓 **Turning on open sharing.** The open sharing permission can now be switched on in the default user permissions, where saving appeared to work but the setting was discarded and came back off. [#27609](https://github.com/open-webui/open-webui/pull/27609), [#27607](https://github.com/open-webui/open-webui/issues/27607) +- 🖌️ **White boxes behind model icons.** Model icons with a transparent background no longer sit on a white square in the admin models list, matching how they already appeared everywhere else. [#27612](https://github.com/open-webui/open-webui/pull/27612), [#27611](https://github.com/open-webui/open-webui/issues/27611) +- 🪞 **Matching the right account at sign-in.** Looking up an account by its identity provider details now matches the exact value, where the stored details were searched as loose text and a value contained within another's could be matched instead. [#28624](https://github.com/open-webui/open-webui/pull/28624) +- 🔄 **Syncing a model catalogue more than once.** Syncing models now updates the ones that already exist, where any repeat of a previous sync silently did nothing at all while still reporting success. [#28036](https://github.com/open-webui/open-webui/pull/28036), [#28033](https://github.com/open-webui/open-webui/issues/28033) +- 🕰️ **Saving a calendar event without a date.** Creating or editing an event with the date cleared now asks for one, where it was sent anyway, refused by the server, and reported as an unreadable error. [Commit](https://github.com/open-webui/open-webui/commit/f100edb70874808c93fab84eae1810595f9e9dc3), [#28133](https://github.com/open-webui/open-webui/issues/28133) +- 🔗 **Deleting a message in a looping chat.** Deleting a message no longer hangs when the conversation contains a cycle in its reply structure. [#28035](https://github.com/open-webui/open-webui/pull/28035) +- 🫀 **Scheduled work stopping without warning.** The routine that runs automations and calendar alerts can no longer be discarded while the application is running, which had silently stopped them firing with nothing reported, and it now stops cleanly on shutdown. [#28053](https://github.com/open-webui/open-webui/pull/28053), [#28052](https://github.com/open-webui/open-webui/issues/28052) +- 🔋 **Session and usage records left uncleared.** The routines that clear out stale sessions and finished model usage can no longer be discarded while the application is running, so those records stop accumulating unnoticed, and both now stop cleanly on shutdown. [#28053](https://github.com/open-webui/open-webui/pull/28053), [#28052](https://github.com/open-webui/open-webui/issues/28052) +- ⚗️ **Reasoning carried between turns.** A model's earlier thinking is now recognised from providers that report it in their own nested field, and reasoning that cannot be sent back without a signature is left out rather than being passed on and rejected. [Commit](https://github.com/open-webui/open-webui/commit/b6dc70c93b0d36e2659438e7a55aa8b21fee27f8) +- 🛎️ **Losing all your settings.** Your interface settings are no longer wiped by a session that failed to load them, which could happen with no action on your part and cleared everything from your theme to your model parameters; saving now changes only the settings you actually changed, and a session that cannot load them tells you instead of carrying on as though you had none. [#27766](https://github.com/open-webui/open-webui/issues/27766), [Commit](https://github.com/open-webui/open-webui/commit/ad8c79f68657bd3bcf5db6be650e498bb904b36b) +- 🖲️ **Losing the collapsed sidebar.** With the sidebar collapsed, opening a chat no longer pushes the narrow sidebar strip off the edge of the screen, which left no way to reopen the sidebar short of shrinking the window to phone size. [#28501](https://github.com/open-webui/open-webui/pull/28501), [#28500](https://github.com/open-webui/open-webui/issues/28500) +- 🧯 **Timers that fail without saying so.** A timer whose reply cannot be generated, such as one set against a model that has since been removed, is now recorded as failed with the reason, instead of being marked as completed while the reply never arrives. [#27785](https://github.com/open-webui/open-webui/pull/27785), [#27783](https://github.com/open-webui/open-webui/issues/27783) +- 🖊️ **Message buttons in channels.** The buttons that appear when you hover a channel message now sit above the message rather than over its content, so they can be clicked on a message that starts with a code block or a table, and so the code and table controls stay clickable too. [#27737](https://github.com/open-webui/open-webui/pull/27737), [#27736](https://github.com/open-webui/open-webui/issues/27736) +- 🔡 **Searching for non-English tags and text.** Searching workspace models by tag, or prompts and automations by their contents, now finds entries containing characters outside the English alphabet, where roughly half were missed depending on which settings were in force when each one was saved. [#28399](https://github.com/open-webui/open-webui/pull/28399) +- 🔭 **Searching the calendar without an end date.** Asking a model to search your calendar without naming an end date now works on PostgreSQL, where the open-ended range was too large for the database to accept and the search failed outright. [Commit](https://github.com/open-webui/open-webui/commit/9550731cc17759f6862595b8cd849ae48695b5c1), [#27717](https://github.com/open-webui/open-webui/issues/27717) +- 🫧 **Attachments replaced by a loading dot.** Pinning a channel message, or otherwise updating one, no longer replaces its attachment with a loading indicator that never resolves until you reload or leave the channel. [Commit](https://github.com/open-webui/open-webui/commit/76d01602950a9e40823b53278557708d5bcd1036), [#27734](https://github.com/open-webui/open-webui/pull/27734), [#27731](https://github.com/open-webui/open-webui/issues/27731) +- 🛠️ **Rebuilding empty server lists on every request.** An instance with no tool servers or no terminal servers configured no longer rebuilds that empty list on every request that needs it. [Commit](https://github.com/open-webui/open-webui/commit/f1a64ccfc2eb2a58086c55fe413a455bb488c35a), [#28568](https://github.com/open-webui/open-webui/issues/28568) +- 🪫 **Errors logged for a cache that was simply empty.** A shared cache that has not been filled yet no longer logs an error suggesting its stored value is broken. [Commit](https://github.com/open-webui/open-webui/commit/f1a64ccfc2eb2a58086c55fe413a455bb488c35a), [#28568](https://github.com/open-webui/open-webui/issues/28568) +- ⚓ **Starting up as a non-root user.** Deployments that run the container as a non-root user, such as Kubernetes setups using runAsNonRoot, start again, where a bundled speech model file that only root could read had stopped them since 0.11.0, and a bundled text corpus is now stored somewhere a non-root user can reach. [#27651](https://github.com/open-webui/open-webui/issues/27651), [Commit](https://github.com/open-webui/open-webui/commit/0480ca9653f0d566eaedadf0af0d785a9938480b), [#28866](https://github.com/open-webui/open-webui/pull/28866) +- 📋 **Finding notes shared read only.** A note shared publicly for reading now appears in the read only view of your notes, where it was readable by anyone with the link but listed nowhere at all. [#27637](https://github.com/open-webui/open-webui/pull/27637), [#27487](https://github.com/open-webui/open-webui/issues/27487) +- 🫂 **Signing in from another application.** Signing in through an application that exchanges a token from your identity provider now applies your role and group memberships the same way signing in through the browser does, and an account whose provider sends no role keeps the one it has rather than being reset to the default. [Commit](https://github.com/open-webui/open-webui/commit/d799e81edbdc971c6deb096b6474cd95b93504bf), [Commit](https://github.com/open-webui/open-webui/commit/e9684458125c3202ebc3378aef32f0b6015171d8) +- 🗄️ **Empty models section in the sidebar.** The models section no longer appears with nothing in it when every pinned model has since been removed, renamed, or hidden. [#27634](https://github.com/open-webui/open-webui/pull/27634), [#27633](https://github.com/open-webui/open-webui/issues/27633) +- 🚧 **The files pane reopening by itself.** With a terminal selected, closing the files pane now keeps it closed, where saving any setting reopened it, including something as incidental as picking an emoji for a folder. [#28693](https://github.com/open-webui/open-webui/pull/28693), [#28691](https://github.com/open-webui/open-webui/issues/28691), [Commit](https://github.com/open-webui/open-webui/commit/c1c81f8127466a4cd59a16d8658155b1b8faa2d0) +- 🚰 **Watching file processing tying up the database.** Waiting for a file or a knowledge base to finish processing no longer holds a database connection open for as long as the page is watching, which on busy instances could use up every available connection and leave the rest of the application unable to reach the database. [#28183](https://github.com/open-webui/open-webui/pull/28183) +- 🗜️ **Embedding settings for other providers.** Saving your embedding settings now writes only the provider you have selected, where it also overwrote the address and key stored for the other two, losing them if their fields were not filled in. [Commit](https://github.com/open-webui/open-webui/commit/87d9b7e84e71b097eadf1df4f9852359104f17ed) +- ↔️ **Connectors in the side-by-side overview.** With the conversation overview laid out left to right, the lines between messages now join at the sides rather than the top and bottom, so they no longer cut across the boxes. [Commit](https://github.com/open-webui/open-webui/commit/6e468c5b9539d3b6057bab63d81e8d2974acff7b) +- 💫 **Thinking indicator with the fade turned off.** Turning off the fade effect for streaming text no longer hides the thinking indicator and its spinner, which had made a reasoning model look as though it had already finished from the first moment it started. [#28559](https://github.com/open-webui/open-webui/issues/28559), [Commit](https://github.com/open-webui/open-webui/commit/b1dc945bd6cf97a7067e603fc1b797301e626fb6) +- 🈳 **Conversations compacted too early on llama.cpp.** A conversation served by llama.cpp is no longer shortened at roughly half the size you configured, where its cached input was counted twice, so a chat showing 39,000 tokens was treated as 77,000 against a 70,000 limit. [#28590](https://github.com/open-webui/open-webui/issues/28590), [Commit](https://github.com/open-webui/open-webui/commit/0b27fa5e873c9f3cff7b14e4eeab8321bdd8970c) +- ◻️ **Settings tabs spilling past the corner.** Scrolling the list of tabs in settings no longer paints a tab or part of an icon across the dialog's rounded bottom corner, where it appeared to sit outside the dialog. [#27617](https://github.com/open-webui/open-webui/pull/27617), [#27616](https://github.com/open-webui/open-webui/issues/27616) +- 🎰 **Settings for a switched-off function.** A function that has been turned off no longer offers its per-user settings, and saving them is refused, where doing so loaded the function's code and stored settings that had no effect. [Commit](https://github.com/open-webui/open-webui/commit/a3a81fee03ba7ec0ceb4d1f456e80a8f4fc2320f) +- 🗃 **Shared folders reordering themselves.** The list of folders shared with you now keeps a consistent order, where on PostgreSQL renaming a folder could shuffle the ones beside it. [#28804](https://github.com/open-webui/open-webui/pull/28804) +- 📜 **System prompt repeated after a tool call.** On models served by a pipe or manifold, the system prompt is no longer added again each time a tool runs, where it built up one extra copy per round and was sent to the provider that way. [#28739](https://github.com/open-webui/open-webui/pull/28739), [#28736](https://github.com/open-webui/open-webui/issues/28736) +- 🔕 **Calendar reminders stopping for everyone.** A single event whose reminder time was stored as something other than a number no longer stops reminders being sent, for that event or for anyone else's, and falls back to the usual reminder window instead. [#28790](https://github.com/open-webui/open-webui/pull/28790) +- 🗨 **Attached conversations reaching the model.** A conversation attached to your message is now listed among its attachments, where it was left out entirely and the model was never told it was there. [#28788](https://github.com/open-webui/open-webui/pull/28788) +- ↔ **Dragging a side panel closed.** Dragging the chat controls, the note chat, or a channel thread panel closed by its edge no longer floods the browser console with errors and leaves stray handlers behind, and the divider can now be moved with the arrow keys once focused. [#28759](https://github.com/open-webui/open-webui/issues/28759), [Commit](https://github.com/open-webui/open-webui/commit/33dff414e829d10dc6691b1cac7457077fa6799c) +- 🎫 **Saving a message with unusual characters on PostgreSQL.** A message carrying characters PostgreSQL will not store outside its text no longer fails to save, where those characters were cleaned from the conversation but passed through raw to the separate message record. [#28820](https://github.com/open-webui/open-webui/pull/28820) +- 🔲 **Removing an item in the model editor.** Unticking a tool, skill, action, or filter no longer leaves the next one in the list looking unticked while it is still selected, needing two clicks to remove and passing the same confusion down the list each time. [#28837](https://github.com/open-webui/open-webui/pull/28837), [#28832](https://github.com/open-webui/open-webui/issues/28832) +- 📉 **Usage figures drifting upward on clustered setups.** The routine that clears out finished model usage no longer stops for good across the whole cluster after a brief interruption, which had left the usage figures counting models nobody was using and grew the work every disconnection had to do. [#28834](https://github.com/open-webui/open-webui/pull/28834) +- 🔇 **Voice mode staying silent with reasoning models.** Voice mode now speaks when the emoji option is on and the model behind it reports its answer as thinking rather than text, where the whole reply went unspoken and nothing reached the speech service at all. [#28724](https://github.com/open-webui/open-webui/pull/28724) +- 🏷 **Tags on a chat shared with you.** Opening a chat shared with you, or one in a shared folder, no longer fails to load its tags, and an administrator opening someone else's chat now sees the tags that chat actually carries. [Commit](https://github.com/open-webui/open-webui/commit/7d4747dfd73d7629227b10ae63c6854cc7543bee), [#28767](https://github.com/open-webui/open-webui/issues/28767) +- 🔻 **Message box controls in a narrow panel.** Narrowing the note chat panel, or squeezing the chat with a wide controls pane, no longer hides the attach and integrations buttons behind the model name or pushes the send button outside the box; the model name is shortened to make room instead. [#28912](https://github.com/open-webui/open-webui/pull/28912), [#28911](https://github.com/open-webui/open-webui/issues/28911) +- ⌛ **Replies that are all thinking and no answer.** A reply from a provider using the responses format that ends while the model is still in its reasoning, with no answer text after it, now finishes normally instead of failing the whole turn and leaving an unreadable error in place of the reply. [#28872](https://github.com/open-webui/open-webui/pull/28872), [#28871](https://github.com/open-webui/open-webui/issues/28871) +- 📃 **Word and PowerPoint previews overflowing.** Previewing one of these files now keeps the document inside its frame, with the zoom and slide controls staying put rather than scrolling away, and a presentation opens on its current slide instead of below the visible area. [#28878](https://github.com/open-webui/open-webui/pull/28878), [#28877](https://github.com/open-webui/open-webui/issues/28877) +- 🎞 **Workspace models in the admin models list.** Workspace models appear in the admin models list again, so they can be ordered, set as the default, and pinned for everyone; choosing one opens its own editor, where it opened the base model editor and could strip the model's base model, turning it into something else. [Commit](https://github.com/open-webui/open-webui/commit/ccbb3303f2ec5db0b16573bbb665a12f6764da66), [#27702](https://github.com/open-webui/open-webui/issues/27702) +- ⌨ **Errors after sending a long message.** With prompt autocompletion on, sending or clearing a message of several paragraphs within a second of typing no longer throws an error in the browser console. [#28824](https://github.com/open-webui/open-webui/pull/28824), [#28823](https://github.com/open-webui/open-webui/issues/28823) +- ❌ **Tool calls that failed looking successful.** A tool call that returned an error is now marked as failed with a red cross rather than a green tick, so a reply built on a failed call is easier to spot. [Commit](https://github.com/open-webui/open-webui/commit/f3f76095d18e07a3a944f22a4b25993bbffe90a3), [#28016](https://github.com/open-webui/open-webui/issues/28016) +- 🗜 **Download links in a cited source doing nothing.** A link in a citation shown as formatted content now downloads the file when clicked, where it silently did nothing at all. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf), [#28924](https://github.com/open-webui/open-webui/issues/28924) +- 🗳 **Web searches failing without saying why.** A web search that fails now explains itself instead of returning nothing at all, which most often happens when a search engine has been selected without its key being configured. [#28942](https://github.com/open-webui/open-webui/pull/28942) +- ✅ **Checklists in notes.** A checklist in a note now previews and downloads as a proper checklist, where each item carried a stray second pair of brackets and its text began two lines below the box. [#27671](https://github.com/open-webui/open-webui/pull/27671), [#26067](https://github.com/open-webui/open-webui/issues/26067) +- 🧿 **Shortening a conversation with the wrong model.** Choosing to shorten long conversations with the model you are chatting with now does that, where it used the configured task model instead on any instance that has one. [Commit](https://github.com/open-webui/open-webui/commit/5093a9938937153e287db27a671f5ba1fb5d7592), [#27603](https://github.com/open-webui/open-webui/issues/27603) +- 🖥 **Stopping a reply after the shared cache restarts.** Stopping a reply now keeps working across a cluster after the shared cache restarts or its connection drops, where the part that carries a stop between instances gave up for good and silently, and only restarting the application brought it back. [Commit](https://github.com/open-webui/open-webui/commit/bf3a58dbcd18ddc2c7f130d8f9529477fe7cb042), [#28909](https://github.com/open-webui/open-webui/issues/28909) +- 📼 **Attached links to media and archives.** Attaching a link that leads to something other than a web page, such as a video or an archive, now reads it as the file it is rather than trying to treat it as text. [Commit](https://github.com/open-webui/open-webui/commit/886248de36e3a60c3687d1bee6af110e14110eca) +- 🗒 **Editing a workflow from settings.** Opening the code editor for a ComfyUI workflow from the images settings now brings it to the front, where it opened behind the settings dialog and could not be reached at all. [#27648](https://github.com/open-webui/open-webui/pull/27648), [#27647](https://github.com/open-webui/open-webui/issues/27647) +- 🖼 **Downloading a generated image.** Downloading an image from its preview now saves the image, where it could silently save a small file containing an authentication error instead, and a download that does fail now says so. [Commit](https://github.com/open-webui/open-webui/commit/2578174637e48cafa4bcb09adbb1b7f4b545a8d5), [#27723](https://github.com/open-webui/open-webui/pull/27723), [#27722](https://github.com/open-webui/open-webui/issues/27722) +- 🗂 **Directory sync listing local accounts.** A directory service syncing accounts over SCIM now sees only the accounts that came from a directory, where it also listed and could modify accounts created with a password in Open WebUI itself. [Commit](https://github.com/open-webui/open-webui/commit/fb4f476316a2f83e4d2914d535c19b3440cbc490) +- ↕ **Sorting a list of people.** Sorting the admin user list, or a channel's member list, now works when no search term has been entered, where the chosen order was ignored unless something was being searched for. [Commit](https://github.com/open-webui/open-webui/commit/fb4f476316a2f83e4d2914d535c19b3440cbc490) +- 🧶 **Text dropped from a reply by a filter.** A filter that rewrites a reply as it streams, or a provider that sends something other than plain text in a chunk, no longer causes that part of the reply to vanish without explanation. [#28840](https://github.com/open-webui/open-webui/pull/28840) +- 🏗 **Timers firing twice after a fork.** Branching a conversation that has a timer set no longer leaves the copy able to fire that timer as well. [#27663](https://github.com/open-webui/open-webui/pull/27663), [#27622](https://github.com/open-webui/open-webui/issues/27622), [#27745](https://github.com/open-webui/open-webui/issues/27745) +- 🗣 **Sentences skipped in voice mode.** Voice mode now speaks every sentence of a reply, where any sentence that completed in the same piece of the reply as another was silently never read out, which happened routinely with providers that send whole paragraphs at a time. [Commit](https://github.com/open-webui/open-webui/commit/495296346edfcd96a506a22fed4bb5b8faf3cd4d), [#28730](https://github.com/open-webui/open-webui/issues/28730), [#19861](https://github.com/open-webui/open-webui/issues/19861) +- 🖊 **Dragging a side panel wider than the window.** A side panel can no longer be dragged so wide that the chat beside it is squeezed away, and dragging one below its minimum width now closes it rather than sticking. [Commit](https://github.com/open-webui/open-webui/commit/ef455fcef9d6cb1136275c26e49bc3c5d6661795), [#28965](https://github.com/open-webui/open-webui/issues/28965) +- 🎙 **The wrong model selected when reopening a chat.** Reopening a conversation where a reply was regenerated with a different model now selects the model behind the reply you are looking at, where it picked the one used for the first attempt, which might be a model no longer available. [#27674](https://github.com/open-webui/open-webui/pull/27674), [#25052](https://github.com/open-webui/open-webui/issues/25052), [Commit](https://github.com/open-webui/open-webui/commit/8a170897bad569d93e069226a996942583ebde80) +- 🛜 **Coding tools that speak Anthropic's format.** A tool such as Cline pointed at Open WebUI using Anthropic's own message format can reach models again, where every request failed before it was even sent, and once that was corrected the request was rejected by Anthropic for being signed the wrong way. [#27675](https://github.com/open-webui/open-webui/pull/27675), [#27595](https://github.com/open-webui/open-webui/issues/27595), [#27695](https://github.com/open-webui/open-webui/issues/27695) +- 👯 **Code in pinned messages.** Opening the pinned messages of a channel now shows the code in those messages, where each block appeared empty and its contents were drawn into the channel behind the dialog instead, doubling them there. [#27740](https://github.com/open-webui/open-webui/pull/27740), [#27739](https://github.com/open-webui/open-webui/issues/27739) +- 🧻 **Logs flooded by an unreachable server.** A terminal or tool server that cannot be reached now records one line per attempt rather than a full stack trace, where a few minutes of downtime could fill the log with hundreds of megabytes and drown out everything else. [#27755](https://github.com/open-webui/open-webui/pull/27755), [#27751](https://github.com/open-webui/open-webui/issues/27751), [#27757](https://github.com/open-webui/open-webui/pull/27757), [#27756](https://github.com/open-webui/open-webui/issues/27756) +- 🖱 **Clicking inside a chat preview.** Clicking an image or a source in the preview that appears when you hover a chat in the sidebar no longer flashes a viewer open and shut, since the preview is meant only to be read. [#27770](https://github.com/open-webui/open-webui/pull/27770), [#27769](https://github.com/open-webui/open-webui/issues/27769) +- 🛢 **Using pgvector with database access by role.** An instance on Amazon RDS that signs in to its database with a temporary credential rather than a stored password now starts when pgvector is the vector store, where the two could not be used together and the container exited on startup. [#27754](https://github.com/open-webui/open-webui/pull/27754), [#27752](https://github.com/open-webui/open-webui/issues/27752) +- 📗 **Opening knowledge attached to a model or folder.** Clicking a knowledge item attached to a model or a folder opens it again, so it can be read and its retrieval mode changed between focused retrieval and the whole document, where since 0.11.0 neither was possible outside a chat. [#27686](https://github.com/open-webui/open-webui/pull/27686), [#27684](https://github.com/open-webui/open-webui/issues/27684), [#27801](https://github.com/open-webui/open-webui/issues/27801), [#28825](https://github.com/open-webui/open-webui/issues/28825) +- 🎚 **Retrieval mode shown from another item.** The retrieval mode shown when opening a knowledge item is now that item's own, where it could show the setting of whichever item was opened before it. [#27686](https://github.com/open-webui/open-webui/pull/27686), [#27684](https://github.com/open-webui/open-webui/issues/27684), [#27801](https://github.com/open-webui/open-webui/issues/27801), [#28825](https://github.com/open-webui/open-webui/issues/28825) +- 🔩 **A new chat shown as nearly full.** The indicator of how full a conversation is now counts tokens the same way the shortening does, where the two read different figures from providers that report both and a fresh chat could appear close to its limit. [#27620](https://github.com/open-webui/open-webui/pull/27620), [#27608](https://github.com/open-webui/open-webui/issues/27608), [Commit](https://github.com/open-webui/open-webui/commit/978d2572140e4fe31ebbc33274f699c71b8cdc29) +- 🫱 **Abandoned changes to a group's sharing setting.** Closing the edit dialog for a user group without saving now discards a change to who can share to that group, where the change stayed on screen and was written to the database the next time anything else about the group was saved. [#28076](https://github.com/open-webui/open-webui/pull/28076), [#28075](https://github.com/open-webui/open-webui/issues/28075) +- 📛 **Editing the wrong group.** The dialog for editing a user group now stays with the group it was opened for, where a reordering of the list beneath it could leave it saving to a different group. [#28076](https://github.com/open-webui/open-webui/pull/28076), [#28075](https://github.com/open-webui/open-webui/issues/28075) +- 🪢 **Signing people out from the identity provider.** A sign-out sent by an identity provider to end someone's session now works, where the check that the message was genuine could fail against providers whose signing keys need the same authentication as everything else, leaving the person signed in. [Commit](https://github.com/open-webui/open-webui/commit/aeda6ff13a25d3b3ba1b303609f35382db22142c) +- 🪣 **Files left behind when a knowledge base is emptied.** Emptying a knowledge base now removes the files it held, along with their stored copies and their search data, where all three were left behind with nothing in the interface to clear them. [Commit](https://github.com/open-webui/open-webui/commit/363ad352fec9553469852d111bc0506b896504a6), [#27988](https://github.com/open-webui/open-webui/issues/27988) +- ♻ **Blank errors when a tool server's saved sign-in cannot be read.** A tool server whose stored sign-in details cannot be decrypted, which happens when "WEBUI_SECRET_KEY" changes since the key protecting them follows it, now names the server and says to reconnect it, where every startup logged two errors with no message at all. [Commit](https://github.com/open-webui/open-webui/commit/91917b23952af8ca4457f8bb3db70c75ab838fbf), [#28666](https://github.com/open-webui/open-webui/pull/28666), [#28665](https://github.com/open-webui/open-webui/issues/28665) +- 🖍 **Clearing the supported media types.** Emptying the supported media types in the documents settings now stays empty, where the previous value came back on the next visit, so images kept being sent to the extraction engine instead of straight to a model that can read them. [#28750](https://github.com/open-webui/open-webui/pull/28750), [#28747](https://github.com/open-webui/open-webui/issues/28747), [Commit](https://github.com/open-webui/open-webui/commit/ecad20b77f9bd700bd818a2f447176f5994320c3) +- 🫳 **Dropping a chat where it already was.** Dragging a chat in the sidebar and releasing it where it started no longer reloads the whole sidebar, which took around a dozen requests for a move that changed nothing. [#28664](https://github.com/open-webui/open-webui/pull/28664), [#28663](https://github.com/open-webui/open-webui/issues/28663) +- 🖲 **Filtering while on a later page.** Changing a filter in the workspace, such as showing only what you created, now returns to the first page, where the list could come back empty with the page controls gone and no way back. [#28734](https://github.com/open-webui/open-webui/issues/28734), [Commit](https://github.com/open-webui/open-webui/commit/a914868e3c83b08be1399dbae1e4da25a0c00c87) +- 🪺 **Workspace counts left behind.** The number beside each workspace tab now follows its list, where creating, copying, importing, or deleting something left the old number in place until you moved to another tab or reloaded, and the tools count ignored its search entirely. [#28983](https://github.com/open-webui/open-webui/pull/28983), [#28981](https://github.com/open-webui/open-webui/issues/28981) +- 📞 **Links that start a voice call.** Opening a link that starts a call now starts one, where it opened the controls pane and stopped there, leaving the only way to begin a call from outside the application broken. [#28721](https://github.com/open-webui/open-webui/pull/28721), [#28677](https://github.com/open-webui/open-webui/issues/28677), [Commit](https://github.com/open-webui/open-webui/commit/8c1f3d382470edb81fa1efdf6cf4fb6bb188c460) +- 🆔 **Signing in where the provider uses a numeric account id.** Signing in through GitHub, or any other provider that identifies people by a number, works again on PostgreSQL, where every attempt failed outright since 0.10.2, and on SQLite the existing account was not matched; accounts stored the old way are corrected on the next sign-in. [Commit](https://github.com/open-webui/open-webui/commit/a6834f089bc2980fced99a617670e785c988cc9f), [#28954](https://github.com/open-webui/open-webui/pull/28954), [#27760](https://github.com/open-webui/open-webui/issues/27760) +- ☎ **Chats opening halfway up.** A conversation whose recent messages are short now opens at the latest message rather than somewhere in the middle, and older messages loaded while scrolling up no longer shift what you were reading. [#28657](https://github.com/open-webui/open-webui/pull/28657), [#28656](https://github.com/open-webui/open-webui/issues/28656) +- 🛤 **The terminal picker vanishing mid-reply.** The terminal picker now stays in place while a reply is being written, greyed out until it finishes, where it disappeared from the message box entirely and took the name of the selected terminal with it. [Commit](https://github.com/open-webui/open-webui/commit/7a533d0d5b85981f8c5668636f931f8b4a97d604) +- 🎧 **Who a channel says its members are.** The member list of a channel now includes its owner and everyone in a group that was granted access, where the owner never appeared and granting both a person and a group they were not in listed nobody at all while the count beside it said two. [#28289](https://github.com/open-webui/open-webui/pull/28289), [#28288](https://github.com/open-webui/open-webui/issues/28288), [Commit](https://github.com/open-webui/open-webui/commit/e3e82b14714661ba5bd24e2c47e42cef5594b132) +- ⛳ **Embedding servers behind a password.** An embedding server protected by a username and password rather than a key now works, where an empty key still sent an authorisation header, which such a server rejected and which made document uploads fail. [#28684](https://github.com/open-webui/open-webui/pull/28684), [#28683](https://github.com/open-webui/open-webui/issues/28683), [Commit](https://github.com/open-webui/open-webui/commit/97466deea105d6fdde60bf4d9401703fd32e03e9) +- ✒ **Note edits lost without warning.** Typing in a note now reaches the database, where a save already waiting could be cancelled without a replacement, leaving the editor showing text that was never stored and nothing to say so; this affected notes written by a model or through the API, and any instance without Redis after a restart. [#28669](https://github.com/open-webui/open-webui/pull/28669), [#28667](https://github.com/open-webui/open-webui/issues/28667) +- 🗄 **Browsing outside the home directory in a terminal.** A terminal server set to allow browsing the whole filesystem can be browsed above the home directory again, where the file panel pinned itself there and opening a file elsewhere quietly did nothing. [#29006](https://github.com/open-webui/open-webui/pull/29006), [#29000](https://github.com/open-webui/open-webui/issues/29000) +- 🏞 **Clearing a model's picture.** A model's picture can be reset to the default logo again, where since 0.11.0 a custom one could only ever be replaced. [#29007](https://github.com/open-webui/open-webui/pull/29007), [#27685](https://github.com/open-webui/open-webui/issues/27685), [Commit](https://github.com/open-webui/open-webui/commit/06e7aac219b3027a9d578624c473b041ad26a692) +- 👤 **Fallback profile pictures.** A profile picture that fails to load, such as one belonging to a deleted account, now falls back to the default avatar instead of showing clipped placeholder text beside the message. [#28270](https://github.com/open-webui/open-webui/pull/28270), [#28269](https://github.com/open-webui/open-webui/issues/28269) +- ⏱️ **Unanswered prompts in tools.** On deployments that set "WEBSOCKET_EVENT_CALLER_TIMEOUT", a question a tool asks you that goes unanswered now reports a timeout rather than an empty reply, and waiting too long no longer risks disconnecting a tab that is still open. [#28311](https://github.com/open-webui/open-webui/pull/28311) +- 👪 **Group member counts updating.** Adding or removing someone from a group in the admin panel now updates that group's member count straight away, where it stayed at the old number until the page was reloaded. [Commit](https://github.com/open-webui/open-webui/commit/18bf0ade7b35fb25ff7920ad3713624d93f04401) +- 🕳️ **Blank messages left in a conversation.** Streamed events that never fill in an item no longer leave an empty assistant message saved in the conversation and sent back to the model on every later turn. [Commit](https://github.com/open-webui/open-webui/commit/2a0274a0a039dbe0a1ad4d24003b085aae7b896b) +- 📓 **Notes opening blank.** A note whose shared editing session has not been started yet now opens with its stored content even when several people open it at once, where previously anyone but a lone first viewer got an empty document. [Commit](https://github.com/open-webui/open-webui/commit/5078d987f83943671f1d23ebe66bcc8d2af902c3) +- 🤫 **Replies stopping silently after a tool ran.** When a provider rejects the follow-up request made after a tool finishes, the reason is now shown in the chat instead of the reply simply ending with nothing said. [Commit](https://github.com/open-webui/open-webui/commit/a610d77137fabf60a2e0daa961a8ef8f7320293a), [#28633](https://github.com/open-webui/open-webui/issues/28633) +- 🎟️ **Connections whose tags were saved as plain text.** A connection with tags stored as plain text no longer breaks its editor panel or silently blanks the tags on every model coming from it. [Commit](https://github.com/open-webui/open-webui/commit/8be4c5fa6a849a9ff4a74f6a2870a115a836e68a), [#28749](https://github.com/open-webui/open-webui/issues/28749) +- 🔌 **Stream filters on direct API calls.** A filter reading a streamed event as an object now works on requests made straight to the chat completions endpoint, matching every other path, where it used to raise and end the reply partway. [Commit](https://github.com/open-webui/open-webui/commit/684111715f742f56a3c7efac9eb7a1e68e54a3ec) +- 🔇 **Filter failures that said nothing.** When a filter's outlet or stream hook raises, the failure is now reported with the filter's name and a traceback at the default log level, where it was swallowed and left plugin authors with nothing to go on. [Commit](https://github.com/open-webui/open-webui/commit/35fbde0a3fb303b7076b03465b1a2b0ba832df53) +- 🪜 **Falling back when a base model is gone.** Chatting with a workspace model whose base model has been removed now falls back to the default model for everyone, where the fallback previously applied only for administrators and anyone else was refused whenever that base model had no workspace entry of its own, such as one supplied by a pipe. [Commit](https://github.com/open-webui/open-webui/commit/20fe43d9da621957c48fc92104bc8f1cc0d691b7) +- 🧠 **Conversations breaking after a model switch.** Switching to a different model once a reasoning model has answered no longer leaves the conversation unusable, where the earlier model's stored reasoning was replayed to a provider that then rejected every later request. [Commit](https://github.com/open-webui/open-webui/commit/c4b3e6840f34a5d267bc12c0ec1a82156a324d45), [#28240](https://github.com/open-webui/open-webui/issues/28240) +- 👻 **Messages disappearing from a conversation.** Two things saving the same conversation at once no longer discard each other's changes, where a message could vanish from the screen while still counting toward the model's context and a file attached during a reply was lost on the next save. [Commit](https://github.com/open-webui/open-webui/commit/1c13fedb16e74c5888c52ccd928a5eb5bbb1068d), [#28742](https://github.com/open-webui/open-webui/issues/28742) +- 🧬 **Workspace models pointing at themselves.** A workspace model whose base model was set to its own identifier is now saved without that reference, where the entry was thrown away while models were being combined so none of its settings ever took effect. [Commit](https://github.com/open-webui/open-webui/commit/eadce55e343df69520e6bc86dbbdfdcdfdcafb30), [#28952](https://github.com/open-webui/open-webui/issues/28952), [#28923](https://github.com/open-webui/open-webui/issues/28923) +- 📭 **A short model list sticking on every worker.** When one worker briefly reports fewer models than it should, that shorter list is no longer left in place for the whole deployment, where chatting with one of the missing models failed on every server until something restarted. [Commit](https://github.com/open-webui/open-webui/commit/6330350a406d8c1fd603f725e59a88324c8e2256), [#28777](https://github.com/open-webui/open-webui/issues/28777) +- 📋 **Copying from the terminal file browser.** Copying a file path or a file's contents now works again, where it silently did nothing on deployments the browser does not treat as a secure origin. [Commit](https://github.com/open-webui/open-webui/commit/aa3d56961049618b28717d87acff1d9360cad47a), [Commit](https://github.com/open-webui/open-webui/commit/2f97c9fce36b3794d14c925086eca78ff92adf4a), [#29015](https://github.com/open-webui/open-webui/issues/29015) +- 🗺️ **Browsing the web through Microsoft Web IQ.** Fetching a page with the web loader set to Microsoft Web IQ now works, where every attempt failed before a single request was made and had done so ever since that loader was added. [Commit](https://github.com/open-webui/open-webui/commit/6dcc2d52692c3fa1993ff1b3ae84c07d35fd9f6b), [Commit](https://github.com/open-webui/open-webui/commit/140d2cf4b59e71d2e9f4986d4d8649fccb47c83d), [#28688](https://github.com/open-webui/open-webui/issues/28688) +- 🔛 **Enable or disable all automations only reaching the ones on screen.** Turning every automation on or off now covers every automation matching your current search and filter, where it only ever touched the ones loaded on the page you were looking at and left the rest running as they were. [Commit](https://github.com/open-webui/open-webui/commit/f4a0d3c9734d1662a3f78891f21934f2b82aed1e) + +### Changed + +- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility. +- 🏋️ **What "THREAD_POOL_SIZE" now sizes.** The setting now governs both of the pools that background work runs in, where it previously governed only one and the other, carrying most of the blocking work including knowledge searches, sign-ins and file storage, was fixed at a small ceiling no setting could raise, so an instance that set it high will now use more threads than before, up to twice the configured value across the two pools, and one that relied on the old ceiling to hold thread use down should review it. [Commit](https://github.com/open-webui/open-webui/commit/4ec6ee14418edd04eaba9e34bd5868453f61df40), [#28168](https://github.com/open-webui/open-webui/issues/28168) +- 💾 **Saving replies as they stream.** The "ENABLE_REALTIME_CHAT_SAVE" setting no longer has any effect, because a reply in progress is now held outside the database and written once when it finishes. [Commit](https://github.com/open-webui/open-webui/commit/a1579a01ff43cacb357269707d36267ad35e01d6) +- 🎭 **Playwright web loader egress.** When a page is fetched with the Playwright loader, the page's own requests for its images, scripts, and stylesheets are now made by the Open WebUI backend instead of by the browser, so administrators using a remote browser through "PLAYWRIGHT_WS_URL" should expect that traffic to leave from the backend's address rather than the browser host, those using a private certificate authority should expect it to be trusted through "AIOHTTP_CLIENT_SSL_CERT_FILE" rather than the browser's own store, and those who set a proxy on the loader should know it no longer applies to these requests, which follow the environment's proxy settings instead. [#28634](https://github.com/open-webui/open-webui/pull/28634) +- 🐌 **Slower Playwright page loads without async.** A page fetched with the Playwright loader on the synchronous path now fetches its images, scripts, and stylesheets one at a time rather than together, which in the change's own measurements took a page with thirty assets from 2.0 to 3.0 seconds, and one with eight slow assets from 1.1 to 4.5 seconds; the asynchronous path is unaffected. [#28634](https://github.com/open-webui/open-webui/pull/28634) +- 🐳 **Test-only packages removed from the image.** The container no longer ships pytest, pytest-docker, the Docker SDK, or netcat, none of which anything in Open WebUI used, so the image is smaller; anyone whose own tools or functions relied on those being present will need to install them themselves. [#28726](https://github.com/open-webui/open-webui/pull/28726) +- 🛡 **Forms in embedded pages now work by default.** A page shown inside a chat, such as an artifact or an HTML preview, may now submit forms unless you turn that off, where it was blocked unless you turned it on. [Commit](https://github.com/open-webui/open-webui/commit/3c66d639e31ba8a7477d337b37d8d671ea2430cf) +- 🅰 **Connection prefixes now show in model names.** A connection's prefix appears in the name shown in the model picker, not only in its identifier, where whether it did depended on the shape of the provider's reply and so worked on some connections and not others; models on prefixed connections will now read differently than before. [#28950](https://github.com/open-webui/open-webui/pull/28950), [#28929](https://github.com/open-webui/open-webui/issues/28929) +- 📊 **Default usage statistics range.** Usage statistics now cover the past two years by default for everyone, instead of starting from the date the account was created. [Commit](https://github.com/open-webui/open-webui/commit/8dbbc206c5a0706789722c42827479e5db10bb2b) + ## [0.11.0] - 2026-07-27 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 59285aa429..66ac54f76c 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -20,7 +20,7 @@ Examples of behavior that contribute to a positive and professional community in - **Respecting others.** Be considerate, listen actively, and engage with empathy toward others' viewpoints and experiences. - **Constructive feedback.** Provide actionable, thoughtful, and respectful feedback that helps improve the project and encourages collaboration. Avoid unproductive negativity or hypercriticism. -- **Recognizing volunteer contributions.** Appreciate that contributors dedicate their free time and resources selflessly. Approach them with gratitude and patience. +- **Recognizing volunteer contributions.** Appreciate that **contributors dedicate their free time and resources selflessly**. Approach them with gratitude and patience. - **Focusing on shared goals.** Collaborate in ways that prioritize the health, success, and sustainability of the community over individual agendas. Examples of unacceptable behavior include: @@ -32,6 +32,7 @@ Examples of unacceptable behavior include: - **Entitlement, demand, or aggression toward contributors.** Volunteers are under no obligation to provide immediate or personalized support. Rude or dismissive behavior will not be tolerated. - **Unproductive or destructive behavior.** This includes venting frustration as hostility ("tantrums"), hypercriticism, attention-seeking negativity, or anything that distracts from the project's goals. - **Spamming and promotional exploitation.** Sharing irrelevant product promotions or self-promotion in the community is not allowed unless it directly contributes value to the discussion. +- Posting low-effort, hard to read, essay-length AI generated comments or other forms of low-quality, hard to parse content that puts the burden of understanding on the reader. ### Feedback and Community Engagement diff --git a/Dockerfile b/Dockerfile index 07b0e0667d..d70244583b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -126,7 +126,7 @@ RUN chown -R $UID:$GID /app $HOME # Install common system dependencies RUN apt-get update && \ apt-get install -y --no-install-recommends \ - git build-essential pandoc gcc netcat-openbsd curl jq ca-certificates \ + git build-essential pandoc gcc curl jq ca-certificates \ libmariadb-dev \ python3-dev \ ffmpeg libsm6 libxext6 zstd \ @@ -149,7 +149,7 @@ RUN set -e; \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ - python -c "import nltk; nltk.download('punkt_tab')"; \ + python -c "import nltk; nltk.download('punkt_tab', download_dir='/usr/local/share/nltk_data')"; \ else \ pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir; \ uv pip install --system -r requirements.txt --no-cache-dir; \ @@ -158,12 +158,18 @@ RUN set -e; \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ - python -c "import nltk; nltk.download('punkt_tab')"; \ + python -c "import nltk; nltk.download('punkt_tab', download_dir='/usr/local/share/nltk_data')"; \ fi; \ fi; \ mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \ + if [ -d /app/backend/data/cache ]; then chmod -R a+rX /app/backend/data/cache; fi; \ rm -rf /var/lib/apt/lists/*; +# Optional: PPTX parsing through unstructured may need spaCy's English model. +# Keep this out of the default image to avoid the extra image bloat; deployments +# with read-only site-packages can uncomment it and bake the model in. +# RUN python -m spacy download en_core_web_sm + # Install Ollama if requested RUN if [ "$USE_OLLAMA" = "true" ]; then \ date +%s > /tmp/ollama_build_hash && \ diff --git a/backend/dev.sh b/backend/dev.sh index 838b93f653..9737b3d005 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -1,3 +1,3 @@ export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080" PORT="${PORT:-8080}" -uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload +uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --ws-per-message-deflate "${UVICORN_WS_PER_MESSAGE_DEFLATE:-true}" --reload diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index 59817d7921..e803cea466 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -18,6 +18,9 @@ def version_callback(value: bool) -> None: if value: from open_webui.env import VERSION + # LICENSE covers this Open WebUI CLI identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. typer.echo(f'Open WebUI version: {VERSION}') raise typer.Exit() @@ -71,7 +74,7 @@ def serve( os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH) import open_webui.main # noqa: F401 - from open_webui.env import UVICORN_WORKERS # Import the workers setting + from open_webui.env import UVICORN_WORKERS, UVICORN_WS_PER_MESSAGE_DEFLATE # On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop, # which is incompatible with psycopg v3 async. Setting loop='none' lets @@ -84,6 +87,7 @@ def serve( port=port, forwarded_allow_ips='*', workers=UVICORN_WORKERS, + ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE, loop=loop, ) @@ -94,12 +98,15 @@ def dev( port: int = 8080, reload: bool = True, ): + from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE + uvicorn.run( 'open_webui.main:app', host=host, port=port, reload=reload, forwarded_allow_ips='*', + ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE, ) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index ec541f5a33..acd3d83bf8 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import base64 -import json import logging import os import shutil @@ -20,6 +19,7 @@ from pydantic import BaseModel from open_webui.env import ( DATA_DIR, DATABASE_URL, + ENABLE_ADMIN_CHAT_ACCESS, ENABLE_DB_MIGRATIONS, ENV, FRONTEND_BUILD_DIR, @@ -35,11 +35,12 @@ from open_webui.env import ( log, ) from open_webui.models.config import Config +from open_webui.utils.json_codec import JSONCodec async def seed_registered_defaults(): await Config.rename_prefix('rag.web', 'web') - await Config.repair_flattened_dict_configs() + await Config.repair_config_rows() await Config.seed_defaults(DEFAULT_CONFIG) @@ -84,7 +85,7 @@ async def import_legacy_config_json(): if not os.path.exists(f'{DATA_DIR}/config.json'): return with open(f'{DATA_DIR}/config.json', 'r') as _f: - await Config.upsert(json.load(_f)) + await Config.upsert(JSONCodec.loads(_f.read())) os.rename(f'{DATA_DIR}/config.json', f'{DATA_DIR}/old_config.json') @@ -114,6 +115,9 @@ for file_path in (FRONTEND_BUILD_DIR / 'static').glob('**/*'): except Exception as e: logging.error(f'An error occurred: {e}') +# LICENSE covers copied Open WebUI logo/favicon assets. +# Do not alter, remove, obscure, or replace them except as LICENSE permits: +# https://docs.openwebui.com/license. frontend_favicon = FRONTEND_BUILD_DIR / 'static' / 'favicon.png' if frontend_favicon.exists(): @@ -181,6 +185,9 @@ CACHE_DIR.mkdir(parents=True, exist_ok=True) # CUSTOM_NAME (Legacy) #################################### +# LICENSE covers this legacy Open WebUI branding path. +# Do not alter, remove, obscure, or replace it except as LICENSE permits: +# https://docs.openwebui.com/license. CUSTOM_NAME = os.getenv('CUSTOM_NAME', '') if CUSTOM_NAME: @@ -270,9 +277,9 @@ def _resolve_ollama_base_url(url: str) -> str: if not default.result() and fallback.result(): url = url.replace(':11434', ':12434') - log.info(f'Ollama port 11434 unreachable on {host}, falling back to 12434') + log.info('Ollama port 11434 unreachable on %s, falling back to 12434', host) elif not default.result(): - log.info(f'Ollama ports 11434 and 12434 both unreachable on {host}') + log.info('Ollama ports 11434 and 12434 both unreachable on %s', host) return url @@ -293,12 +300,12 @@ OLLAMA_API_CONFIGS = {} _ollama_api_configs = os.getenv('OLLAMA_API_CONFIGS', '') if _ollama_api_configs: try: - parsed = json.loads(_ollama_api_configs) + parsed = JSONCodec.loads(_ollama_api_configs) if isinstance(parsed, dict): OLLAMA_API_CONFIGS = parsed else: log.warning('OLLAMA_API_CONFIGS must be a JSON object, ignoring') - except (json.JSONDecodeError, TypeError): + except (JSONCodec.JSONDecodeError, TypeError): log.warning('OLLAMA_API_CONFIGS is not valid JSON, ignoring') #################################### @@ -340,12 +347,12 @@ OPENAI_API_CONFIGS = {} _openai_api_configs = os.getenv('OPENAI_API_CONFIGS', '') if _openai_api_configs: try: - parsed = json.loads(_openai_api_configs) + parsed = JSONCodec.loads(_openai_api_configs) if isinstance(parsed, dict): OPENAI_API_CONFIGS = parsed else: log.warning('OPENAI_API_CONFIGS must be a JSON object, ignoring') - except (json.JSONDecodeError, TypeError): + except (JSONCodec.JSONDecodeError, TypeError): log.warning('OPENAI_API_CONFIGS is not valid JSON, ignoring') # Get the actual OpenAI API key based on the base URL @@ -369,7 +376,7 @@ ENABLE_BASE_MODELS_CACHE = os.getenv('ENABLE_BASE_MODELS_CACHE', 'False').lower( #################################### try: - tool_server_connections = json.loads(os.getenv('TOOL_SERVER_CONNECTIONS', '[]')) + tool_server_connections = JSONCodec.loads(os.getenv('TOOL_SERVER_CONNECTIONS', '[]')) except Exception as e: log.exception(f'Error loading TOOL_SERVER_CONNECTIONS: {e}') tool_server_connections = [] @@ -383,12 +390,12 @@ OAUTH_CLIENT_TIMEOUT = os.getenv('OAUTH_CLIENT_TIMEOUT', '') # TERMINAL_SERVER #################################### -terminal_server_connections = json.loads(os.getenv('TERMINAL_SERVER_CONNECTIONS', '[]')) +terminal_server_connections = JSONCodec.loads(os.getenv('TERMINAL_SERVER_CONNECTIONS', '[]')) TERMINAL_SERVER_CONNECTIONS = terminal_server_connections try: - TERMINAL_PROXY_HEADERS = json.loads(os.getenv('TERMINAL_PROXY_HEADERS', '{}')) + TERMINAL_PROXY_HEADERS = JSONCodec.loads(os.getenv('TERMINAL_PROXY_HEADERS', '{}')) except Exception: TERMINAL_PROXY_HEADERS = {} @@ -805,7 +812,7 @@ if VECTOR_DB == 'oracle23ai': 'Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication.' ) -log.info(f'VECTOR_DB: {VECTOR_DB}') +log.info('VECTOR_DB: %s', VECTOR_DB) # S3 Vector S3_VECTOR_BUCKET_NAME = os.getenv('S3_VECTOR_BUCKET_NAME', None) @@ -894,8 +901,8 @@ MINERU_API_KEY = os.getenv('MINERU_API_KEY', '') mineru_params = os.getenv('MINERU_PARAMS', '') try: - mineru_params = json.loads(mineru_params) -except json.JSONDecodeError: + mineru_params = JSONCodec.loads(mineru_params) +except JSONCodec.JSONDecodeError: mineru_params = {} MINERU_PARAMS = mineru_params @@ -908,8 +915,8 @@ EXTERNAL_DOCUMENT_LOADER_API_KEY = os.getenv('EXTERNAL_DOCUMENT_LOADER_API_KEY', external_document_loader_headers = os.getenv('EXTERNAL_DOCUMENT_LOADER_HEADERS', '') try: - external_document_loader_headers = json.loads(external_document_loader_headers) -except json.JSONDecodeError: + external_document_loader_headers = JSONCodec.loads(external_document_loader_headers) +except JSONCodec.JSONDecodeError: external_document_loader_headers = {} if not isinstance(external_document_loader_headers, dict): external_document_loader_headers = {} @@ -918,14 +925,16 @@ EXTERNAL_DOCUMENT_LOADER_HEADERS = external_document_loader_headers TIKA_SERVER_URL = os.getenv('TIKA_SERVER_URL', 'http://tika:9998') +TIKA_SERVER_VERSION = os.getenv('TIKA_SERVER_VERSION', '3') + DOCLING_SERVER_URL = os.getenv('DOCLING_SERVER_URL', 'http://docling:5001') DOCLING_API_KEY = os.getenv('DOCLING_API_KEY', '') docling_params = os.getenv('DOCLING_PARAMS', '') try: - docling_params = json.loads(docling_params) -except json.JSONDecodeError: + docling_params = JSONCodec.loads(docling_params) +except JSONCodec.JSONDecodeError: docling_params = {} DOCLING_PARAMS = docling_params @@ -966,6 +975,8 @@ RAG_FILE_MAX_COUNT = int(os.getenv('RAG_FILE_MAX_COUNT')) if os.getenv('RAG_FILE RAG_FILE_MAX_SIZE = int(os.getenv('RAG_FILE_MAX_SIZE')) if os.getenv('RAG_FILE_MAX_SIZE') else None +ENABLE_KNOWLEDGE_FILE_RETENTION = os.getenv('ENABLE_KNOWLEDGE_FILE_RETENTION', 'False').lower() == 'true' + RAG_FILE_CONTENT_SEARCH_MAX_CHARS = int(os.getenv('RAG_FILE_CONTENT_SEARCH_MAX_CHARS', str(64 * 1024 * 1024))) FILE_IMAGE_COMPRESSION_WIDTH = ( @@ -988,7 +999,7 @@ PDF_EXTRACT_IMAGES = os.getenv('PDF_EXTRACT_IMAGES', 'False').lower() == 'true' PDF_LOADER_MODE = os.getenv('PDF_LOADER_MODE', 'page') RAG_EMBEDDING_MODEL = os.getenv('RAG_EMBEDDING_MODEL', 'sentence-transformers/all-MiniLM-L6-v2') -log.info(f'Embedding model set: {RAG_EMBEDDING_MODEL}') +log.info('Embedding model set: %s', RAG_EMBEDDING_MODEL) RAG_TOKENIZER_MODEL = os.getenv('RAG_TOKENIZER_MODEL', '') @@ -1016,7 +1027,7 @@ RAG_RERANKING_ENGINE = os.getenv('RAG_RERANKING_ENGINE', '') RAG_RERANKING_MODEL = os.getenv('RAG_RERANKING_MODEL', '') if RAG_RERANKING_MODEL != '': - log.info(f'Reranking model set: {RAG_RERANKING_MODEL}') + log.info('Reranking model set: %s', RAG_RERANKING_MODEL) RAG_RERANKING_MODEL_AUTO_UPDATE = ( @@ -1100,12 +1111,26 @@ ENABLE_LOCAL_WEB_FETCH = ( ENABLE_RAG_LOCAL_WEB_FETCH = ENABLE_LOCAL_WEB_FETCH +# Operators extend this through WEB_FETCH_FILTER_LIST. DEFAULT_WEB_FETCH_FILTER_LIST = [ '!169.254.169.254', '!fd00:ec2::254', '!metadata.google.internal', '!metadata.azure.com', '!100.100.100.200', + '!168.63.129.16', # Azure platform channel, reachable from every Azure VM + '!192.88.99.0/24', # 6to4 relay anycast, deprecated by RFC 7526 + '!224.0.0.0/4', # IPv4 multicast + '!::ffff:0:0:0/96', # IPv4-translated (SIIT, RFC 2765), never routed + '!64:ff9b:1::/48', # NAT64 local-use prefix, RFC 8215, not a public destination + '!100:0:0:1::/64', # dummy prefix, RFC 9780 + '!2001:1::1', # PCP anycast, RFC 7723, answered by the local network's own edge device + '!2001:1::2', # TURN anycast, RFC 8155, likewise + '!2001:20::/28', # ORCHIDv2, RFC 7343, never routed + '!2001:30::/28', # DRIP, RFC 9374, never routed + '!5f00::/16', # SRv6 SIDs, RFC 9602, internal to one segment routing domain + '!fec0::/10', # IPv6 site-local, deprecated by RFC 3879 + '!ff00::/8', # IPv6 multicast ] web_fetch_filter_list = os.getenv('WEB_FETCH_FILTER_LIST', '') @@ -1148,7 +1173,7 @@ WEB_SEARCH_RESULT_COUNT = int(os.getenv('WEB_SEARCH_RESULT_COUNT', '3')) try: - web_search_domain_filter_list = json.loads(os.getenv('WEB_SEARCH_DOMAIN_FILTER_LIST', '[]')) + web_search_domain_filter_list = JSONCodec.loads(os.getenv('WEB_SEARCH_DOMAIN_FILTER_LIST', '[]')) except Exception as e: web_search_domain_filter_list = [ # "wikipedia.com", @@ -1297,8 +1322,8 @@ LINKUP_API_KEY = os.getenv('LINKUP_API_KEY', '') linkup_search_params = os.getenv('LINKUP_SEARCH_PARAMS', '') try: - linkup_search_params = json.loads(linkup_search_params) -except json.JSONDecodeError: + linkup_search_params = JSONCodec.loads(linkup_search_params) +except JSONCodec.JSONDecodeError: linkup_search_params = {} LINKUP_SEARCH_PARAMS = linkup_search_params @@ -1330,8 +1355,8 @@ AUTOMATIC1111_API_AUTH = os.getenv('AUTOMATIC1111_API_AUTH', '') automatic1111_params = os.getenv('AUTOMATIC1111_PARAMS', '') try: - automatic1111_params = json.loads(automatic1111_params) -except json.JSONDecodeError: + automatic1111_params = JSONCodec.loads(automatic1111_params) +except JSONCodec.JSONDecodeError: automatic1111_params = {} AUTOMATIC1111_PARAMS = automatic1111_params @@ -1455,8 +1480,8 @@ COMFYUI_WORKFLOW = os.getenv('COMFYUI_WORKFLOW', COMFYUI_DEFAULT_WORKFLOW) comfyui_workflow_nodes = os.getenv('COMFYUI_WORKFLOW_NODES', '') try: - comfyui_workflow_nodes = json.loads(comfyui_workflow_nodes) -except json.JSONDecodeError: + comfyui_workflow_nodes = JSONCodec.loads(comfyui_workflow_nodes) +except JSONCodec.JSONDecodeError: comfyui_workflow_nodes = [] COMFYUI_WORKFLOW_NODES = comfyui_workflow_nodes @@ -1468,8 +1493,8 @@ IMAGES_OPENAI_API_KEY = os.getenv('IMAGES_OPENAI_API_KEY', OPENAI_API_KEY) images_openai_params = os.getenv('IMAGES_OPENAI_PARAMS', '') try: - images_openai_params = json.loads(images_openai_params) -except json.JSONDecodeError: + images_openai_params = JSONCodec.loads(images_openai_params) +except JSONCodec.JSONDecodeError: images_openai_params = {} @@ -1507,8 +1532,8 @@ IMAGES_EDIT_COMFYUI_WORKFLOW = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW', '') images_edit_comfyui_workflow_nodes = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW_NODES', '') try: - images_edit_comfyui_workflow_nodes = json.loads(images_edit_comfyui_workflow_nodes) -except json.JSONDecodeError: + images_edit_comfyui_workflow_nodes = JSONCodec.loads(images_edit_comfyui_workflow_nodes) +except JSONCodec.JSONDecodeError: images_edit_comfyui_workflow_nodes = [] IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = images_edit_comfyui_workflow_nodes @@ -1582,8 +1607,8 @@ AUDIO_TTS_OPENAI_API_KEY = os.getenv('AUDIO_TTS_OPENAI_API_KEY', OPENAI_API_KEY) audio_tts_openai_params = os.getenv('AUDIO_TTS_OPENAI_PARAMS', '') try: - audio_tts_openai_params = json.loads(audio_tts_openai_params) -except json.JSONDecodeError: + audio_tts_openai_params = JSONCodec.loads(audio_tts_openai_params) +except JSONCodec.JSONDecodeError: audio_tts_openai_params = {} AUDIO_TTS_OPENAI_PARAMS = audio_tts_openai_params @@ -1635,7 +1660,7 @@ DEFAULT_MODELS = os.getenv('DEFAULT_MODELS', None) DEFAULT_PINNED_MODELS = os.getenv('DEFAULT_PINNED_MODELS', None) try: - default_prompt_suggestions = json.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', '[]')) + default_prompt_suggestions = JSONCodec.loads(os.getenv('DEFAULT_PROMPT_SUGGESTIONS', '[]')) except Exception as e: log.exception(f'Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}') default_prompt_suggestions = [] @@ -1673,7 +1698,7 @@ if default_prompt_suggestions == []: DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions try: - model_order_list = json.loads(os.getenv('MODEL_ORDER_LIST', '[]')) + model_order_list = JSONCodec.loads(os.getenv('MODEL_ORDER_LIST', '[]')) except Exception as e: log.exception(f'Error loading MODEL_ORDER_LIST: {e}') model_order_list = [] @@ -1681,7 +1706,7 @@ except Exception as e: MODEL_ORDER_LIST = model_order_list try: - default_model_metadata = json.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}')) + default_model_metadata = JSONCodec.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}')) except Exception as e: log.exception(f'Error loading DEFAULT_MODEL_METADATA: {e}') default_model_metadata = {} @@ -1689,13 +1714,22 @@ except Exception as e: DEFAULT_MODEL_METADATA = default_model_metadata try: - default_model_params = json.loads(os.getenv('DEFAULT_MODEL_PARAMS', '{}')) + default_model_params = JSONCodec.loads(os.getenv('DEFAULT_MODEL_PARAMS', '{}')) except Exception as e: log.exception(f'Error loading DEFAULT_MODEL_PARAMS: {e}') default_model_params = {} DEFAULT_MODEL_PARAMS = default_model_params + +try: + default_interface_settings = JSONCodec.loads(os.getenv('DEFAULT_INTERFACE_SETTINGS', '{}')) +except Exception as e: + log.exception(f'Error loading DEFAULT_INTERFACE_SETTINGS: {e}') + default_interface_settings = {} + +DEFAULT_INTERFACE_SETTINGS = default_interface_settings if isinstance(default_interface_settings, dict) else {} + DEFAULT_USER_ROLE = os.getenv('DEFAULT_USER_ROLE', 'pending') DEFAULT_GROUP_ID = os.getenv('DEFAULT_GROUP_ID', '') @@ -2032,7 +2066,7 @@ ENABLE_USER_STATUS = os.getenv('ENABLE_USER_STATUS', 'True').lower() == 'true' ENABLE_EVALUATION_ARENA_MODELS = os.getenv('ENABLE_EVALUATION_ARENA_MODELS', 'True').lower() == 'true' try: - evaluation_arena_models = json.loads(os.getenv('EVALUATION_ARENA_MODELS', '[]')) + evaluation_arena_models = JSONCodec.loads(os.getenv('EVALUATION_ARENA_MODELS', '[]')) if not isinstance(evaluation_arena_models, list) or not all( isinstance(model, dict) for model in evaluation_arena_models ): @@ -2047,6 +2081,9 @@ DEFAULT_ARENA_MODEL = { 'id': 'arena-model', 'name': 'Arena Model', 'meta': { + # LICENSE covers this Open WebUI fallback logo. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'profile_image_url': '/favicon.png', 'description': 'Submit your questions to anonymous AI chatbots and vote on the best response.', 'model_ids': None, @@ -2067,8 +2104,6 @@ BYPASS_ADMIN_ACCESS_CONTROL = ( == 'true' ) -ENABLE_ADMIN_CHAT_ACCESS = os.getenv('ENABLE_ADMIN_CHAT_ACCESS', 'True').lower() == 'true' - ENABLE_ADMIN_ANALYTICS = os.getenv('ENABLE_ADMIN_ANALYTICS', 'True').lower() == 'true' ENABLE_COMMUNITY_SHARING = os.getenv('ENABLE_COMMUNITY_SHARING', 'True').lower() == 'true' @@ -2079,6 +2114,7 @@ ENABLE_USER_WEBHOOKS = os.getenv('ENABLE_USER_WEBHOOKS', 'False').lower() == 'tr # FastAPI / AnyIO settings THREAD_POOL_SIZE = os.getenv('THREAD_POOL_SIZE', None) +THREAD_POOL_THREAD_NAME_PREFIX = os.getenv('THREAD_POOL_THREAD_NAME_PREFIX', '') if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str): try: @@ -2134,7 +2170,7 @@ class BannerModel(BaseModel): try: - banners = json.loads(os.getenv('WEBUI_BANNERS', '[]')) + banners = JSONCodec.loads(os.getenv('WEBUI_BANNERS', '[]')) banners = [BannerModel(**banner) for banner in banners] except Exception as e: log.exception(f'Error loading WEBUI_BANNERS: {e}') @@ -2157,10 +2193,20 @@ TASK_MODEL = os.getenv('TASK_MODEL', '') TASK_MODEL_EXTERNAL = os.getenv('TASK_MODEL_EXTERNAL', '') +try: + task_model_params = JSONCodec.loads(os.getenv('TASK_MODEL_PARAMS', '{}')) +except Exception as e: + log.exception(f'Error loading TASK_MODEL_PARAMS: {e}') + task_model_params = {} + +TASK_MODEL_PARAMS = task_model_params + CONTEXT_COMPACTION_MODEL = os.getenv('CONTEXT_COMPACTION_MODEL', '') ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lower() == 'true' +ENABLE_TOOL_PERMISSIONS = os.getenv('ENABLE_TOOL_PERMISSIONS', 'False').lower() == 'true' + CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000')) _CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP') @@ -2470,12 +2516,12 @@ GOOGLE_OAUTH_AUTHORIZE_PARAMS = {} _google_oauth_authorize_params = os.getenv('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '') if _google_oauth_authorize_params: try: - _parsed = json.loads(_google_oauth_authorize_params) + _parsed = JSONCodec.loads(_google_oauth_authorize_params) if isinstance(_parsed, dict): GOOGLE_OAUTH_AUTHORIZE_PARAMS = _parsed else: log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring') - except (json.JSONDecodeError, TypeError): + except (JSONCodec.JSONDecodeError, TypeError): log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') MICROSOFT_CLIENT_ID = os.getenv('MICROSOFT_CLIENT_ID', '') @@ -2590,12 +2636,12 @@ OAUTH_AUTHORIZE_PARAMS = {} _oauth_authorize_params = os.getenv('OAUTH_AUTHORIZE_PARAMS', '') if _oauth_authorize_params: try: - _parsed = json.loads(_oauth_authorize_params) + _parsed = JSONCodec.loads(_oauth_authorize_params) if isinstance(_parsed, dict): OAUTH_AUTHORIZE_PARAMS = _parsed else: log.warning('OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring') - except (json.JSONDecodeError, TypeError): + except (JSONCodec.JSONDecodeError, TypeError): log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') @@ -2848,6 +2894,7 @@ DEFAULT_CONFIG = { 'rag.external_document_loader_api_key': EXTERNAL_DOCUMENT_LOADER_API_KEY, 'rag.external_document_loader_headers': EXTERNAL_DOCUMENT_LOADER_HEADERS, 'rag.tika_server_url': TIKA_SERVER_URL, + 'rag.tika_server_version': TIKA_SERVER_VERSION, 'rag.docling_server_url': DOCLING_SERVER_URL, 'rag.docling_api_key': DOCLING_API_KEY, 'rag.docling_params': DOCLING_PARAMS, @@ -3046,6 +3093,7 @@ DEFAULT_CONFIG = { 'ui.default_locale': DEFAULT_LOCALE, 'ui.default_models': DEFAULT_MODELS, 'ui.default_pinned_models': DEFAULT_PINNED_MODELS, + 'ui.default_interface_settings': DEFAULT_INTERFACE_SETTINGS, 'ui.prompt_suggestions': DEFAULT_PROMPT_SUGGESTIONS, 'ui.model_order_list': MODEL_ORDER_LIST, 'models.default_metadata': DEFAULT_MODEL_METADATA, @@ -3085,12 +3133,14 @@ DEFAULT_CONFIG = { 'auth.admin.email': ADMIN_EMAIL, 'task.model.default': TASK_MODEL, 'task.model.external': TASK_MODEL_EXTERNAL, + 'task.model.params': TASK_MODEL_PARAMS, 'chat.context_compaction.model': CONTEXT_COMPACTION_MODEL, 'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION, 'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD, 'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP, 'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE, 'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE, + 'chat.tool_permissions.enable': ENABLE_TOOL_PERMISSIONS, 'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE, 'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE, 'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index c4d6b8f3db..3ce8d6cf26 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -99,7 +99,7 @@ class ERROR_MESSAGES(str, Enum): INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.' - WEB_SEARCH_ERROR = lambda err='': err if err else 'Something went wrong while searching the web.' + WEB_SEARCH_ERROR = 'Something went wrong while searching the web.' OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.' @@ -118,9 +118,15 @@ class ERROR_MESSAGES(str, Enum): AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.' AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}' AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences' + AUTOMATION_COUNT_REQUIRES_DTSTART = ( + 'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window' + ) FEATURE_DISABLED = lambda name='': f'{name} is disabled' INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}' + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error' REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty' OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured" diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 6c49b48638..b17df7340a 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -227,7 +227,7 @@ if FROM_INIT_PY: # Check if the data directory exists in the package directory if DATA_DIR.exists() and DATA_DIR != NEW_DATA_DIR: - log.info(f'Moving {DATA_DIR} to {NEW_DATA_DIR}') + log.info('Moving %s to %s', DATA_DIR, NEW_DATA_DIR) for item in DATA_DIR.iterdir(): dest = NEW_DATA_DIR / item.name if item.is_dir(): @@ -353,18 +353,18 @@ DATABASE_SQLITE_PRAGMA_MMAP_SIZE = os.getenv('DATABASE_SQLITE_PRAGMA_MMAP_SIZE', # truncated. 67108864 ≈ 64 MB. Set to -1 for no limit (SQLite default). DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT = os.getenv('DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT', '67108864') -DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.getenv('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', None) -if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None: - try: - DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) - except Exception: - DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0 +# Seconds between presence writes per user per worker; keep under the 180s active-user window. 0 disables. +try: + DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(os.getenv('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', '60')) +except ValueError: + DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 60.0 DATABASE_ENABLE_SESSION_SHARING = os.getenv('DATABASE_ENABLE_SESSION_SHARING', 'False').lower() == 'true' ENABLE_PUBLIC_ACTIVE_USERS_COUNT = os.getenv('ENABLE_PUBLIC_ACTIVE_USERS_COUNT', 'True').lower() == 'true' RESET_CONFIG_ON_START = os.getenv('RESET_CONFIG_ON_START', 'False').lower() == 'true' ENABLE_REALTIME_CHAT_SAVE = os.getenv('ENABLE_REALTIME_CHAT_SAVE', 'False').lower() == 'true' ENABLE_QUERIES_CACHE = os.getenv('ENABLE_QUERIES_CACHE', 'False').lower() == 'true' +ENABLE_ADMIN_CHAT_ACCESS = os.getenv('ENABLE_ADMIN_CHAT_ACCESS', 'True').lower() == 'true' RAG_SYSTEM_CONTEXT = os.getenv('RAG_SYSTEM_CONTEXT', 'False').lower() == 'true' #################################### @@ -376,6 +376,11 @@ REDIS_CLUSTER = os.getenv('REDIS_CLUSTER', 'False').lower() == 'true' REDIS_KEY_PREFIX = os.getenv('REDIS_KEY_PREFIX', 'open-webui') +try: + REDIS_RESPONSE_STREAM_TTL = int(os.getenv('REDIS_RESPONSE_STREAM_TTL', '3600')) +except ValueError: + REDIS_RESPONSE_STREAM_TTL = 3600 + REDIS_SENTINEL_HOSTS = os.getenv('REDIS_SENTINEL_HOSTS', '') REDIS_SENTINEL_PORT = os.getenv('REDIS_SENTINEL_PORT', '26379') @@ -443,6 +448,9 @@ try: except (ValueError, TypeError): UVICORN_WORKERS = 1 +# tiny delta-stream frames make per-frame websocket compression CPU-bound, allow opting out (true/false) +UVICORN_WS_PER_MESSAGE_DEFLATE = os.getenv('UVICORN_WS_PER_MESSAGE_DEFLATE', 'True').lower() == 'true' + #################################### # WEBSOCKET SUPPORT #################################### @@ -499,6 +507,15 @@ try: except ValueError: WEBSOCKET_SERVER_PING_INTERVAL = 25 +WEBSOCKET_HEARTBEAT_INTERVAL = os.getenv('WEBSOCKET_HEARTBEAT_INTERVAL', '') +if WEBSOCKET_HEARTBEAT_INTERVAL == '': + WEBSOCKET_HEARTBEAT_INTERVAL = None +else: + try: + WEBSOCKET_HEARTBEAT_INTERVAL = min(max(int(WEBSOCKET_HEARTBEAT_INTERVAL), 5), 90) + except ValueError: + WEBSOCKET_HEARTBEAT_INTERVAL = 30 + WEBSOCKET_EVENT_CALLER_TIMEOUT = os.getenv('WEBSOCKET_EVENT_CALLER_TIMEOUT', '') if WEBSOCKET_EVENT_CALLER_TIMEOUT == '': @@ -571,6 +588,8 @@ def _parse_ssl_env(value: str) -> 'bool | _ssl.SSLContext': REQUESTS_VERIFY = os.getenv('REQUESTS_VERIFY', 'True').lower() == 'true' +TAVILY_API_BASE_URL = os.getenv('TAVILY_API_BASE_URL', 'https://api.tavily.com').rstrip('/') + _aiohttp_timeout_raw = os.getenv('AIOHTTP_CLIENT_TIMEOUT', '') try: AIOHTTP_CLIENT_TIMEOUT = int(_aiohttp_timeout_raw) if _aiohttp_timeout_raw else None @@ -602,6 +621,18 @@ SEARXNG_CLIENT_KEY_FILE = os.getenv('SEARXNG_CLIENT_KEY_FILE', '').strip() # When False (default), outbound HTTP requests do not follow 3xx redirects. AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.getenv('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true' +# Opt-in c-ares DNS resolution (aiodns). Off by default: c-ares breaks name +# resolution in some environments (#28013, #28215). Must run before any +# TCPConnector is constructed. +AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER = os.getenv('AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER', 'False').lower() == 'true' + +if not AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER: + import aiohttp + + aiohttp.DefaultResolver = aiohttp.resolver.ThreadedResolver # for plugin code + aiohttp.resolver.DefaultResolver = aiohttp.resolver.ThreadedResolver + aiohttp.connector.DefaultResolver = aiohttp.resolver.ThreadedResolver + # Optional User-Agent override for outbound web-loader fetches. When set, # SafeWebBaseLoader sends this value instead of the default python-requests UA # which is aggressively blocked by Cloudflare, Wikipedia, and similar services. @@ -791,6 +822,16 @@ BYPASS_RETRIEVAL_ACCESS_CONTROL = os.getenv('BYPASS_RETRIEVAL_ACCESS_CONTROL', ' # for non-admin users. When False (default), unknown collection names are # denied — closing the legacy unscoped namespace. ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS = os.getenv('ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS', 'False').lower() == 'true' + +# Falls back to the upload size limit, because a document cannot legitimately carry more metadata +# than the file itself is allowed to be. Left unbounded, a small archive that expands enormously +# during extraction can exhaust memory. RAG_FILE_MAX_SIZE is in MB. +RAG_METADATA_MAX_VALUE_CHARS = ( + int(os.getenv('RAG_METADATA_MAX_VALUE_CHARS')) + if os.getenv('RAG_METADATA_MAX_VALUE_CHARS') + else ((int(os.getenv('RAG_FILE_MAX_SIZE', '0')) or 0) * 1024 * 1024 or None) +) + MINERU_MAX_MARKDOWN_BYTES = ( int(os.getenv('MINERU_MAX_MARKDOWN_BYTES')) if os.getenv('MINERU_MAX_MARKDOWN_BYTES') else None ) @@ -888,10 +929,18 @@ if LICENSE_PUBLIC_KEY: # WEBUI Identity #################################### +# LICENSE covers this Open WebUI branding surface, including name, logo, +# visual, textual, symbolic identifiers, metadata, and surrounding UI. +# Do not alter, remove, obscure, or replace it except as LICENSE permits: +# https://docs.openwebui.com/license. WEBUI_NAME = os.getenv('WEBUI_NAME', 'Open WebUI') if WEBUI_NAME != 'Open WebUI': WEBUI_NAME += ' (Open WebUI)' +# LICENSE covers this Open WebUI branding surface, including this favicon +# and any visual, textual, or symbolic identifiers it preserves. +# Do not alter, remove, obscure, or replace it except as LICENSE permits: +# https://docs.openwebui.com/license. WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png' WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build') TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '') @@ -948,6 +997,10 @@ except ValueError: # Progressive Web App #################################### +# LICENSE covers this install-time Open WebUI branding surface, including +# names, logos, manifests, metadata, and surrounding UI. +# Do not alter, remove, obscure, or replace it except as LICENSE permits: +# https://docs.openwebui.com/license. EXTERNAL_PWA_MANIFEST_URL = os.getenv('EXTERNAL_PWA_MANIFEST_URL', None) #################################### diff --git a/backend/open_webui/events.py b/backend/open_webui/events.py index 999a1c826c..81b8cc3968 100644 --- a/backend/open_webui/events.py +++ b/backend/open_webui/events.py @@ -411,6 +411,11 @@ class EventDefinitions(BaseModel): description='Retrieval content was processed.', message='Retrieval Content processed', ) + RETRIEVAL_CONTENT_PROCESS_FAILED: EventDefinition = EventDefinition( + name='retrieval.content.process_failed', + description='Retrieval content processing failed.', + message='Retrieval Content process failed', + ) RETRIEVAL_COLLECTION_DELETED: EventDefinition = EventDefinition( name='retrieval.collection.deleted', description='A retrieval collection was deleted.', @@ -666,6 +671,7 @@ NOTIFICATION_EVENTS = ( EVENTS.CHAT_FAILED.name, EVENTS.CHANNEL_MESSAGE.name, EVENTS.CALENDAR_ALERT.name, + EVENTS.RETRIEVAL_CONTENT_PROCESS_FAILED.name, ) @@ -1026,6 +1032,9 @@ def build_event( async def dispatch_webhook_event(app: Any, event: Event) -> None: + # LICENSE covers this Open WebUI webhook identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI') subject = event.subject or {} subject_id = subject.get('id') @@ -1077,6 +1086,20 @@ class NotificationEventSink: schedule_notification_dispatch(app, event) +class SocketSessionEventSink: + async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None: + if event.event not in {EVENTS.USER_DELETED.name, EVENTS.USER_ROLE_UPDATED.name}: + return + + subject = event.subject or {} + if subject.get('type') != 'user' or not subject.get('id'): + return + + from open_webui.socket.main import disconnect_user_sessions + + await disconnect_user_sessions(str(subject['id'])) + + async def dispatch_event_functions( app: Any, event: Event, request: Any | None = None, extra_function_ids: list[str] | None = None ) -> None: @@ -1145,7 +1168,7 @@ class EventFunctionSink: schedule_event_function_dispatch(app, event, request) -EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()] +EVENT_SINKS = [SocketSessionEventSink(), EventFunctionSink(), WebhookEventSink(), NotificationEventSink()] async def publish_event( diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 1900ee752c..41e1a42d9e 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -1,6 +1,5 @@ import asyncio import inspect -import json import logging import sys from typing import AsyncGenerator, Generator, Iterator @@ -29,6 +28,7 @@ from open_webui.socket.main import ( get_event_emitter, ) from open_webui.utils.access_control import check_model_access +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import ( add_or_update_system_message, get_last_user_message, @@ -100,7 +100,7 @@ async def get_function_models(request): log.exception(e) sub_pipes = [] - log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}") + log.debug("get_function_models: function '%s' is a manifold of %s", pipe.id, sub_pipes) for p in sub_pipes: sub_pipe_id = f'{pipe.id}.{p["id"]}' @@ -126,7 +126,10 @@ async def get_function_models(request): pipe_flag = {'type': 'pipe'} log.debug( - f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}" + "get_function_models: function '%s' is a single pipe { 'id': %s, 'name': %s }", + pipe.id, + pipe.id, + pipe.name, ) pipe_models.append( @@ -170,7 +173,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di line = line.model_dump_json() line = f'data: {line}' if isinstance(line, dict): - line = f'data: {json.dumps(line)}' + line = f'data: {JSONCodec.dumps(line)}' try: line = line.decode('utf-8') @@ -181,7 +184,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di return f'{line}\n\n' else: line = openai_chat_chunk_message_template(form_data['model'], line) - return f'data: {json.dumps(line)}\n\n' + return f'data: {JSONCodec.dumps(line)}\n\n' def get_pipe_id(form_data: dict) -> str: pipe_id = form_data['model'] @@ -209,6 +212,9 @@ async def generate_function_chat_completion(request, form_data, user, models: di return params + # Set server-side by utils/chat.py, never by client input. Mirrors the routers. + bypass_system_prompt = getattr(request.state, 'bypass_system_prompt', False) + # Copy so the base-model substitution below doesn't leak into the caller's # payload, which the tool-call continuation re-submits. Mirrors the routers. form_data = {**form_data} @@ -288,7 +294,8 @@ async def generate_function_chat_completion(request, form_data, user, models: di if params: system = params.pop('system', None) form_data = apply_model_params_to_body_openai(params, form_data) - form_data = await apply_system_prompt_to_body(system, form_data, metadata, user) + if not bypass_system_prompt: + form_data = await apply_system_prompt_to_body(system, form_data, metadata, user) pipe_id = get_pipe_id(form_data) function_module = await get_function_module_by_id(request, pipe_id) @@ -308,17 +315,17 @@ async def generate_function_chat_completion(request, form_data, user, models: di yield data return if isinstance(res, dict): - yield f'data: {json.dumps(res)}\n\n' + yield f'data: {JSONCodec.dumps(res)}\n\n' return except Exception as e: log.error(f'Error: {e}') - yield f'data: {json.dumps({"error": {"detail": str(e)}})}\n\n' + yield f'data: {JSONCodec.dumps({"error": {"detail": str(e)}})}\n\n' return if isinstance(res, str): message = openai_chat_chunk_message_template(form_data['model'], res) - yield f'data: {json.dumps(message)}\n\n' + yield f'data: {JSONCodec.dumps(message)}\n\n' if isinstance(res, Iterator): for line in res: @@ -330,7 +337,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di finish_message = openai_chat_chunk_message_template(form_data['model'], '') finish_message['choices'][0]['finish_reason'] = 'stop' - yield f'data: {json.dumps(finish_message)}\n\n' + yield f'data: {JSONCodec.dumps(finish_message)}\n\n' yield 'data: [DONE]' return StreamingResponse(stream_content(), media_type='text/event-stream') diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index acce09ed6d..aa96d2f2a0 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import os import sys @@ -28,6 +27,7 @@ from open_webui.env import ( ENABLE_DB_MIGRATIONS, OPEN_WEBUI_DIR, ) +from open_webui.utils.json_codec import JSONCodec from sqlalchemy import Dialect, MetaData, create_engine, event, types from sqlalchemy.engine.url import make_url from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -124,18 +124,18 @@ class JSONField(types.TypeDecorator): # TEXT-backed JSON storage """Store arbitrary Python objects as JSON-encoded TEXT. Used instead of native JSON columns for portability across SQLite and - PostgreSQL. Values are serialized with ``json.dumps`` on write and - deserialized with ``json.loads`` on read. + PostgreSQL. Values are serialized with ``JSONCodec.dumps`` on write and + deserialized with ``JSONCodec.loads`` on read. """ impl = types.UnicodeText cache_ok = True def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any: - return json.dumps(value) if value is not None else None + return JSONCodec.dumps(value) if value is not None else None def process_result_value(self, value: _T | None, dialect: Dialect) -> Any: - return json.loads(value) if value is not None else None + return JSONCodec.loads(value) if value is not None else None def copy(self, **kwargs: Any) -> Self: return JSONField(length=self.impl.length) @@ -202,6 +202,20 @@ def enable_iam_token_auth(connectable) -> None: return engine = getattr(connectable, 'sync_engine', connectable) + url = engine.url + auth = _rds_iam_token_auth + # The token is bound to one host/port/user pair; leave other databases on their own credentials. + if (url.host, url.port or 5432, url.username) != (auth.host, auth.port, auth.username): + log.warning( + 'AWS RDS IAM token auth not applied to %s: the token is issued for %s@%s:%s, ' + 'so this connection uses the password from its own URL', + url.render_as_string(hide_password=True), + auth.username, + auth.host, + auth.port, + ) + return + if not event.contains(engine, 'do_connect', _set_iam_token_password): event.listen(engine, 'do_connect', _set_iam_token_password) @@ -232,6 +246,27 @@ def _make_async_url(url: str) -> str: return url +def _json_codec_kwargs(kwargs: dict) -> dict: + """Default an engine to JSONCodec for native ``JSON`` columns. + + Unlike ``JSONField``, those serialize through the engine, which otherwise uses + stdlib ``json``. With ``ENABLE_ORJSON`` off JSONCodec is stdlib ``json`` anyway. + """ + kwargs.setdefault('json_serializer', JSONCodec.dumps) + kwargs.setdefault('json_deserializer', JSONCodec.loads) + return kwargs + + +def _create_engine(*args, **kwargs): + """``create_engine`` with the app JSON codec wired in.""" + return create_engine(*args, **_json_codec_kwargs(kwargs)) + + +def _create_async_engine(*args, **kwargs): + """``create_async_engine`` with the app JSON codec wired in.""" + return create_async_engine(*args, **_json_codec_kwargs(kwargs)) + + # ============================================================ # SYNC ENGINE (used only for: startup migrations, config loading, # Alembic, peewee migration, health checks) @@ -260,7 +295,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'): # in the native sqlcipher3 C library. Use NullPool by default for safety, # or QueuePool if DATABASE_POOL_SIZE is explicitly configured. if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0: - engine = create_engine( + engine = _create_engine( 'sqlite://', creator=create_sqlcipher_connection, pool_size=DATABASE_POOL_SIZE, @@ -272,7 +307,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'): echo=False, ) else: - engine = create_engine( + engine = _create_engine( 'sqlite://', creator=create_sqlcipher_connection, poolclass=NullPool, @@ -282,7 +317,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'): log.info('Connected to encrypted SQLite database using SQLCipher') elif 'sqlite' in SQLALCHEMY_DATABASE_URL: - engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False}) + engine = _create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False}) def _apply_sqlite_pragmas(dbapi_connection): """Apply all configured SQLite PRAGMAs to a raw DBAPI connection.""" @@ -314,7 +349,7 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL: else: if isinstance(DATABASE_POOL_SIZE, int): if DATABASE_POOL_SIZE > 0: - engine = create_engine( + engine = _create_engine( SQLALCHEMY_DATABASE_URL, pool_size=DATABASE_POOL_SIZE, max_overflow=DATABASE_POOL_MAX_OVERFLOW, @@ -324,9 +359,9 @@ else: poolclass=QueuePool, ) else: - engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool) + engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool) else: - engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) + engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) enable_iam_token_auth(engine) @@ -373,7 +408,7 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: # No pool_pre_ping: a local SQLite file cannot drop connections, and the # ping costs a worker-thread hop plus a SELECT 1 on every checkout. _sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512 - async_engine = create_async_engine( + async_engine = _create_async_engine( ASYNC_SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False}, pool_size=_sqlite_pool_size, @@ -387,7 +422,7 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: else: if isinstance(DATABASE_POOL_SIZE, int): if DATABASE_POOL_SIZE > 0: - async_engine = create_async_engine( + async_engine = _create_async_engine( ASYNC_SQLALCHEMY_DATABASE_URL, pool_size=DATABASE_POOL_SIZE, max_overflow=DATABASE_POOL_MAX_OVERFLOW, @@ -396,13 +431,13 @@ else: pool_pre_ping=True, ) else: - async_engine = create_async_engine( + async_engine = _create_async_engine( ASYNC_SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool, ) else: - async_engine = create_async_engine( + async_engine = _create_async_engine( ASYNC_SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, ) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 55613ac83d..c84a394b87 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1,17 +1,19 @@ from __future__ import annotations import asyncio -import json +import copy import logging import mimetypes import os import sys import time +from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from uuid import uuid4 import aiohttp import anyio.to_thread +from cryptography.fernet import InvalidToken from fastapi import ( Depends, FastAPI, @@ -64,6 +66,7 @@ from open_webui.config import ( ONEDRIVE_SHAREPOINT_URL, STATIC_DIR, THREAD_POOL_SIZE, + THREAD_POOL_THREAD_NAME_PREFIX, WEBUI_AUTH, WEBUI_NAME, async_reset_config, @@ -83,19 +86,19 @@ from open_webui.env import ( ENABLE_COMPRESSION_MIDDLEWARE, ENABLE_CUSTOM_MODEL_FALLBACK, ENABLE_EASTER_EGGS, - ENABLE_PLUGINS, - EXTERNAL_PWA_MANIFEST_URL, # OAuth Back-Channel Logout ENABLE_OAUTH_BACKCHANNEL_LOGOUT, ENABLE_OTEL, + ENABLE_PLUGINS, ENABLE_PUBLIC_ACTIVE_USERS_COUNT, + ENABLE_PYODIDE_FILE_PERSISTENCE, # SCIM ENABLE_SCIM, ENABLE_SIGNUP_PASSWORD_CONFIRMATION, ENABLE_STAR_SESSIONS_MIDDLEWARE, - ENABLE_PYODIDE_FILE_PERSISTENCE, ENABLE_VERSION_UPDATE_CHECK, ENABLE_WEBSOCKET_SUPPORT, + EXTERNAL_PWA_MANIFEST_URL, GLOBAL_LOG_LEVEL, INSTANCE_ID, LICENSE_KEY, @@ -108,6 +111,7 @@ from open_webui.env import ( SAFE_MODE, SCIM_TOKEN, VERSION, + WEBSOCKET_HEARTBEAT_INTERVAL, # Admin Account Runtime Creation WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_NAME, @@ -121,12 +125,14 @@ from open_webui.env import ( from open_webui.events import ( EVENTS, delete_event_webhook, - get_event_catalog as get_event_catalog_items, get_event_webhooks, migrate_legacy_webhook_config, publish_event, upsert_event_webhook, ) +from open_webui.events import ( + get_event_catalog as get_event_catalog_items, +) from open_webui.internal.db import engine, get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels @@ -134,7 +140,7 @@ from open_webui.models.chats import ChatForm, Chats from open_webui.models.config import Config from open_webui.models.functions import Functions from open_webui.models.messages import Messages -from open_webui.models.models import Models +from open_webui.models.models import Models, normalize_model_tags from open_webui.models.users import Users from open_webui.routers import ( analytics, @@ -154,8 +160,8 @@ from open_webui.routers import ( knowledge, memories, models, - notifications, notes, + notifications, ollama, openai, pipelines, @@ -198,13 +204,9 @@ from open_webui.tasks import ( ) # Import from tasks.py from open_webui.utils import logger from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control.folders import has_folder_write_access from open_webui.utils.actions import chat_action as chat_action_handler -from open_webui.utils.asgi_middleware import ( - AuthTokenMiddleware, - CommitSessionMiddleware, - RedirectMiddleware, - WebsocketUpgradeGuardMiddleware, -) +from open_webui.utils.asgi_middleware import AppHTTPMiddleware from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware from open_webui.utils.auth import ( create_admin_user, @@ -229,14 +231,17 @@ from open_webui.utils.chat_variables import ( normalize_chat_variables, ) from open_webui.utils.embeddings import generate_embeddings +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.json_response import apply_orjson_http_json from open_webui.utils.logger import start_logger from open_webui.utils.middleware import ( background_tasks_handler, build_chat_response_context, + drain_approved_tool_calls, process_chat_payload, process_chat_response, ) +from open_webui.utils.misc import get_response_error_detail, merge_model_params from open_webui.utils.model_ids import strip_provider_model_prefix from open_webui.utils.models import ( check_model_access, @@ -258,8 +263,12 @@ from open_webui.utils.oauth import ( ) from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.redis import get_redis_client -from open_webui.utils.security_headers import SecurityHeadersMiddleware -from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper +from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper +from open_webui.utils.tool_approval import ( + ResolveToolCallForm, + build_tool_approval_resume_payload, + resolve_tool_call_output, +) from open_webui.utils.tools import set_terminal_servers, set_tool_servers if SAFE_MODE: @@ -320,6 +329,9 @@ https://github.com/open-webui/open-webui print(banner) except UnicodeEncodeError: # Stdout can't encode the box-drawing banner (Windows cp1252, redirected/headless stdout); fall back to ASCII. + # LICENSE covers this Open WebUI CLI identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. print(f'Open WebUI v{VERSION} - building the best AI user interface.\nhttps://github.com/open-webui/open-webui') @@ -329,6 +341,16 @@ async def lifespan(app: FastAPI): # This allows sync functions to schedule work on the main loop without blocking health checks app.state.main_loop = asyncio.get_running_loop() + if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0: + # asyncio offloads bypass AnyIO's limiter, so configure both before the first offload. + anyio.to_thread.current_default_thread_limiter().total_tokens = THREAD_POOL_SIZE + app.state.main_loop.set_default_executor( + ThreadPoolExecutor( + max_workers=THREAD_POOL_SIZE, + thread_name_prefix=THREAD_POOL_THREAD_NAME_PREFIX, + ) + ) + app.state.instance_id = INSTANCE_ID start_logger() @@ -364,16 +386,12 @@ async def lifespan(app: FastAPI): if app.state.redis is not None: app.state.redis_task_command_listener = asyncio.create_task(redis_task_command_listener(app)) - if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0: - limiter = anyio.to_thread.current_default_thread_limiter() - limiter.total_tokens = THREAD_POOL_SIZE - - asyncio.create_task(periodic_usage_pool_cleanup()) - asyncio.create_task(periodic_session_pool_cleanup()) + app.state.periodic_usage_pool_cleanup = asyncio.create_task(periodic_usage_pool_cleanup()) + app.state.periodic_session_pool_cleanup = asyncio.create_task(periodic_session_pool_cleanup()) from open_webui.utils.automations import scheduler_worker_loop - asyncio.create_task(scheduler_worker_loop(app)) + app.state.scheduler_worker_loop = asyncio.create_task(scheduler_worker_loop(app)) if await Config.get('models.base_models_cache'): try: @@ -420,13 +438,13 @@ async def lifespan(app: FastAPI): log.info('Initializing tool servers...') try: await set_tool_servers(mock_request) - log.info(f'Initialized {len(app.state.TOOL_SERVERS)} tool server(s)') + log.info('Initialized %s tool server(s)', len(app.state.TOOL_SERVERS)) except Exception as e: log.warning(f'Failed to initialize tool servers at startup: {e}') try: await set_terminal_servers(mock_request) - log.info(f'Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)') + log.info('Initialized %s terminal server(s)', len(app.state.TERMINAL_SERVERS)) except Exception as e: log.warning(f'Failed to initialize terminal servers at startup: {e}') @@ -454,6 +472,10 @@ async def lifespan(app: FastAPI): if hasattr(app.state, 'redis_task_command_listener'): app.state.redis_task_command_listener.cancel() + app.state.periodic_usage_pool_cleanup.cancel() + app.state.periodic_session_pool_cleanup.cancel() + app.state.scheduler_worker_loop.cancel() + await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system') @@ -461,6 +483,9 @@ async def lifespan(app: FastAPI): # response_model routes keep FastAPI's Pydantic fast path either way. apply_orjson_http_json() +# LICENSE covers this Open WebUI API metadata identifier. +# Do not alter, remove, obscure, or replace it except as LICENSE permits: +# https://docs.openwebui.com/license. app = FastAPI( title='Open WebUI', docs_url='/docs' if ENV == 'dev' else None, @@ -483,6 +508,10 @@ app.state.oauth_client_manager = oauth_client_manager app.state.instance_id = None app.state.redis = None +# LICENSE covers this Open WebUI branding surface, including name, logo, +# visual, textual, symbolic identifiers, metadata, and surrounding UI. +# Do not alter, remove, obscure, or replace it except as LICENSE permits: +# https://docs.openwebui.com/license. app.state.WEBUI_NAME = WEBUI_NAME app.state.LICENSE_METADATA = None app.state.USER_COUNT = None @@ -592,8 +621,18 @@ async def initialize_runtime_config(app: FastAPI): f'mcp:{server_id}', OAuthClientInformationFull(**oauth_client_info), ) + except InvalidToken: + log.error( + 'Error adding OAuth client for MCP tool server %s: InvalidToken. ' + 'Stored OAuth client data is invalid; reconnect this tool server.', + server_id, + ) except Exception as e: - log.error(f'Error adding OAuth client for MCP tool server {server_id}: {e}') + log.error( + 'Error adding OAuth client for MCP tool server %s: %s', + server_id, + f'{type(e).__name__}: {e}' if str(e) else type(e).__name__, + ) arena_models = await Config.get('evaluation.arena.models', []) or [] if any('access_control' in m.get('meta', {}) for m in arena_models): @@ -763,11 +802,7 @@ if ENABLE_COMPRESSION_MIDDLEWARE: # `terminate_force_close` tracebacks under aiosqlite and as random # CancelledError storms across the request path. See # `open_webui.utils.asgi_middleware` for the rationale. -app.add_middleware(RedirectMiddleware) -app.add_middleware(SecurityHeadersMiddleware) -app.add_middleware(CommitSessionMiddleware) -app.add_middleware(AuthTokenMiddleware, fastapi_app=app) -app.add_middleware(WebsocketUpgradeGuardMiddleware) +app.add_middleware(AppHTTPMiddleware) app.add_middleware( @@ -855,19 +890,17 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v models = await get_filtered_models(models, user) for model in models: + info = model.get('info') if isinstance(model.get('info'), dict) else {} + meta = info.get('meta') if isinstance(info.get('meta'), dict) else {} + # Remove profile image URL to reduce payload size - if model.get('info', {}).get('meta', {}).get('profile_image_url'): - model['info']['meta'].pop('profile_image_url', None) + meta.pop('profile_image_url', None) - try: - model_tags = [tag.get('name') for tag in model.get('info', {}).get('meta', {}).get('tags', [])] - tags = [tag.get('name') for tag in model.get('tags', [])] + if 'tags' in meta: + meta['tags'] = normalize_model_tags(meta['tags']) - tags = list(set(model_tags + tags)) - model['tags'] = [{'name': tag} for tag in tags] - except Exception as e: - log.debug(f'Error processing model tags: {e}') - model['tags'] = [] + tags = normalize_model_tags(meta.get('tags')) + normalize_model_tags(model.get('tags')) + model['tags'] = list({tag['name']: tag for tag in tags}.values()) model_order_list = await Config.get('ui.model_order_list') if model_order_list: @@ -882,7 +915,7 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v if log.isEnabledFor(logging.DEBUG): log.debug( - f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}' + f'/api/models returned filtered models accessible to the user: {JSONCodec.dumps([model.get("id") for model in models])}' ) return {'data': models} @@ -935,7 +968,7 @@ async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depend prefix_id = api_config.get('prefix_id', None) actual_model = strip_provider_model_prefix(model_id, prefix_id) - payload = json.dumps({'model': actual_model, 'keep_alive': 0, 'prompt': ''}) + payload = JSONCodec.dumps({'model': actual_model, 'keep_alive': 0, 'prompt': ''}) try: timeout = aiohttp.ClientTimeout(total=30) @@ -1066,53 +1099,67 @@ async def chat_completion( metadata = {} try: model_info = None + fallback_model = None + missing_base_model = False if not model_item.get('direct', False): if model_id not in request.app.state.MODELS: raise Exception('Model not found') model = request.app.state.MODELS[model_id] model_info = await Models.get_model_by_id(model_id) + missing_base_model = bool( + model_info and model_info.base_model_id and model_info.base_model_id not in request.app.state.MODELS + ) + + if missing_base_model and ENABLE_CUSTOM_MODEL_FALLBACK: + fallback_model_id = next( + ( + model_id.strip() + for model_id in ((await Config.get('ui.default_models')) or '').split(',') + if model_id.strip() + ), + None, + ) + if fallback_model_id: + fallback_model = request.app.state.MODELS.get(fallback_model_id) # Check if user has access to the model if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL): try: - await check_model_access(user, model, model_info=model_info) + access_model_info = ( + model_info.model_copy(update={'base_model_id': None}) + if fallback_model is not None + else model_info + ) + await check_model_access(user, model, model_info=access_model_info) + if fallback_model is not None: + await check_model_access(user, fallback_model) except Exception as e: raise e else: model = model_item await _set_direct_model(request, model, user) + # Read before the fallback below can rebind model to a different one. + model_capabilities = ((model.get('info') or {}).get('meta') or {}).get('capabilities') or {} + # Model params: global defaults as base, per-model overrides win - default_model_params = await Config.get('models.default_params', {}) or {} - model_info_params = { - **default_model_params, - **(model_info.params.model_dump() if model_info and model_info.params else {}), - } + default_model_params = copy.deepcopy(await Config.get('models.default_params', {}) or {}) + model_info_params = merge_model_params( + default_model_params, + model_info.params.model_dump() if model_info and model_info.params else {}, + ) request_params = {key: value for key, value in (form_data.get('params') or {}).items() if value is not None} if model_info_params or request_params: - form_data['params'] = { - **model_info_params, - **request_params, - } + form_data['params'] = merge_model_params(model_info_params, request_params) # Check base model existence for custom models - if model_info and model_info.base_model_id: - base_model_id = model_info.base_model_id - if base_model_id not in request.app.state.MODELS: - if ENABLE_CUSTOM_MODEL_FALLBACK: - default_models = ((await Config.get('ui.default_models')) or '').split(',') - - fallback_model_id = default_models[0].strip() if default_models[0] else None - - 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: - raise Exception('Model not found') + if missing_base_model: + if fallback_model is None: + raise Exception('Model not found') + # Update model and form_data so routing uses the fallback model's type + model = fallback_model + form_data['model'] = fallback_model['id'] # Chat Params stream_delta_chunk_size = form_data.get('params', {}).get('stream_delta_chunk_size') @@ -1123,6 +1170,10 @@ async def chat_completion( if model_info_params.get('stream_response') is not None: form_data['stream'] = model_info_params.get('stream_response') + # Providers only report token counts when asked, so ask on every caller's behalf. + if form_data.get('stream') and model_capabilities.get('usage'): + form_data['stream_options'] = {**(form_data.get('stream_options') or {}), 'include_usage': True} + if model_info_params.get('stream_delta_chunk_size'): stream_delta_chunk_size = model_info_params.get('stream_delta_chunk_size') @@ -1155,7 +1206,7 @@ async def chat_completion( message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}] user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None) - chat_id = form_data.get('chat_id') or '' + chat_id = form_data.pop('chat_id', None) or '' chat_variables = form_data.pop('chat_variables', None) if chat_variables is None: existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None @@ -1177,15 +1228,28 @@ async def chat_completion( ): tool_servers = None + automation_id = form_data.pop('automation_id', None) + tool_approval_mode = ( + 'full' + if automation_id or chat_id.startswith('channel:') + else ( + form_data.get('params', {}).get('tool_approval_mode') + if await Config.get('chat.tool_permissions.enable', False) + else 'full' + ) + or 'full' + ) + metadata = { 'user_id': user.id, 'user_agent': request.headers.get('user-agent', '') or '', 'internal': getattr(request.state, 'internal', False) is True, - 'chat_id': form_data.pop('chat_id', None) or '', + 'chat_id': chat_id, 'user_message': user_message, 'user_message_id': user_message.get('id') if user_message else None, 'assistant_message_id': form_data.pop('assistant_message_id', None), 'session_id': form_data.pop('session_id', None), + 'automation_id': automation_id, 'folder_id': form_data.pop('folder_id', None), 'filter_ids': form_data.pop('filter_ids', []), 'tool_ids': form_data.get('tool_ids', None), @@ -1205,6 +1269,7 @@ async def chat_completion( or model_info_params.get('function_calling') or 'native' ), + 'tool_approval_mode': tool_approval_mode, }, } @@ -1218,8 +1283,8 @@ async def chat_completion( if metadata.get('chat_id') and user: chat_id = metadata['chat_id'] - # Gate channel: branch — caller needs write access on the channel - # and the supplied message_id must belong to that channel. + # Gate channel: branch — caller needs write access on the channel, and the + # supplied message_id must belong to that channel and be the caller's own. if chat_id.startswith('channel:'): channel_id = chat_id.removeprefix('channel:') channel = await Channels.get_channel_by_id(channel_id) @@ -1251,7 +1316,11 @@ async def chat_completion( if not target_message_id: continue target_message = await Messages.get_message_by_id(target_message_id) - if target_message and target_message.channel_id != channel.id: + if target_message and ( + target_message.channel_id != channel.id + # Write access is not authorship — block cross-member edits. + or (user.role != 'admin' and target_message.user_id != user.id) + ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT(), @@ -1259,6 +1328,14 @@ async def chat_completion( if is_saved_chat_id(chat_id): if is_new_chat: + # The chat created below is persisted with this folder_id. + folder_id = metadata['folder_id'] + if folder_id is not None and not await has_folder_write_access(user.id, folder_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + # Build the full history upfront with ALL assistant placeholders user_message = metadata.get('user_message') or {} user_message_id = user_message.get('id') if user_message else None @@ -1365,7 +1442,7 @@ async def chat_completion( user.id, ) except Exception as e: - log.debug(f'Error inserting chat files: {e}') + log.debug('Error inserting chat files: %s', e) pass if initial_title_generation is not None and all_assistant_ids: @@ -1387,7 +1464,7 @@ async def chat_completion( try: await background_tasks_handler(title_ctx) except Exception as e: - log.debug(f'Error generating initial chat title: {e}') + log.debug('Error generating initial chat title: %s', e) asyncio.create_task(run_initial_title_generation()) else: @@ -1407,15 +1484,13 @@ async def chat_completion( # The old frontend saveChatHandler did this on every message; # now the backend owns persistence. chat_files = metadata.get('files') - if chat_files is not None or selected_chat_models: - existing_chat = await Chats.get_chat_by_id(chat_id) - if existing_chat: - updated = {**existing_chat.chat} - if chat_files is not None: - updated['files'] = chat_files - if selected_chat_models: - updated['models'] = selected_chat_models - await Chats.update_chat_by_id(chat_id, updated, touch=False) + chat_fields = {} + if chat_files is not None: + chat_fields['files'] = chat_files + if selected_chat_models: + chat_fields['models'] = selected_chat_models + if chat_fields: + await Chats.update_chat_by_id(chat_id, chat_fields, touch=False) await Chats.update_chat_variables_by_id(chat_id, chat_variables) @@ -1475,7 +1550,7 @@ async def chat_completion( user.id, ) except Exception as e: - log.debug(f'Error inserting chat files: {e}') + log.debug('Error inserting chat files: %s', e) pass # Save ALL assistant placeholders @@ -1500,6 +1575,8 @@ async def chat_completion( for entry in message_ids: target_model_id = entry['model_id'] assistant_message_id = entry['message_id'] + if assistant_message_id and assistant_message_id == metadata.get('assistant_message_id'): + continue if assistant_message_id: assistant_message = { 'id': assistant_message_id, @@ -1548,6 +1625,9 @@ async def chat_completion( try: form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model) + if await drain_approved_tool_calls(request, form_data, user, model, metadata): + return {'status': True, 'chat_id': metadata.get('chat_id'), 'paused': True} + response = await chat_completion_handler(request, form_data, user) # When the upstream provider returns an error (e.g. HTTP 400 @@ -1556,14 +1636,7 @@ async def chat_completion( # raise so the except-block below emits chat:message:error + # chat:tasks:cancel, unblocking the frontend. if isinstance(response, JSONResponse) and response.status_code >= 400: - try: - error_body = json.loads(response.body.decode('utf-8', 'replace')) - detail = error_body.get('error', error_body) if isinstance(error_body, dict) else error_body - if isinstance(detail, dict): - detail = detail.get('message', detail.get('detail', str(detail))) - except Exception: - detail = f'Provider returned HTTP {response.status_code}' - raise Exception(detail) + raise Exception(get_response_error_detail(response)) ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events) @@ -1641,9 +1714,9 @@ async def chat_completion( try: await client.disconnect() except BaseException as e: - log.debug(f'Error disconnecting MCP client: {e}') + log.debug('Error disconnecting MCP client: %s', e) except BaseException as e: - log.debug(f'Error cleaning up MCP clients: {e}') + log.debug('Error cleaning up MCP clients: %s', e) # Deregister this task, then emit chat:active=false if no others remain try: @@ -1789,6 +1862,27 @@ async def chat_completion( generate_chat_completions = chat_completion generate_chat_completion = chat_completion + +@app.post('/api/v1/chats/{id}/messages/{message_id}/resolve') +async def resolve_chat_message_tool_call( + request: Request, + id: str, + message_id: str, + form_data: ResolveToolCallForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + resolution = await resolve_tool_call_output(id, message_id, form_data, user, db=db) + payload = await build_tool_approval_resume_payload(id, message_id, chat=resolution['chat']) + result = await chat_completion(request, payload, user) + return { + 'status': True, + 'chat_id': id, + 'message_id': message_id, + **(result if isinstance(result, dict) else {}), + } + + # Expose as app.state so internal callers (e.g. automations) can # use the full pipeline without importing from main.py (avoids circular deps). app.state.CHAT_COMPLETION_HANDLER = chat_completion @@ -1820,7 +1914,7 @@ async def count_message_tokens( async def passthrough_anthropic_messages(request: Request, form_data: dict, user) -> Response | dict: - requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_token_count_target( + requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_request_target( request, form_data, user ) request_url = f'{url.rstrip("/")}/messages' @@ -1832,11 +1926,11 @@ async def passthrough_anthropic_messages(request: Request, form_data: dict, user response = await session.request( method='POST', url=request_url, - data=json.dumps(payload), + data=JSONCodec.dumps(payload), headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, - timeout=aiohttp.ClientTimeout(total=openai.AIOHTTP_CLIENT_TIMEOUT), + timeout=get_client_timeout(stream=bool(payload.get('stream'))), ) if 'text/event-stream' in response.headers.get('Content-Type', ''): @@ -1925,13 +2019,6 @@ async def generate_messages( # Convert Anthropic payload to OpenAI format openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params) - model_meta = model_info.meta.model_dump() if model_info and model_info.meta else {} - if (model_meta.get('capabilities') or {}).get('usage') is True: - if openai_payload.get('stream'): - stream_options = openai_payload.get('stream_options') - if not isinstance(stream_options, dict): - stream_options = {} - openai_payload['stream_options'] = {**stream_options, 'include_usage': True} # Route through the existing chat_completion handler response = await chat_completion(request, openai_payload, user) @@ -2039,13 +2126,14 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id) - log.debug(f'Task IDs for chat {chat_id}: {task_ids}') + log.debug('Task IDs for chat %s: %s', chat_id, task_ids) return {'task_ids': task_ids} @app.post('/api/tasks/chat/{chat_id:path}/stop') async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): socket_id = get_temporary_chat_session_id(chat_id) + chat = None if socket_id: owner_id = get_user_id_from_session_pool(socket_id) if owner_id != user.id and user.role != 'admin': @@ -2055,6 +2143,47 @@ async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De if chat is None or (chat.user_id != user.id and user.role != 'admin'): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) result = await stop_item_tasks(request.app.state.redis, chat_id) + + if not socket_id and str(result.get('message', '')).startswith('No tasks found'): + messages_map = await Chats.get_messages_map_by_chat_id(chat_id) or {} + for message_id, message in messages_map.items(): + if message.get('role') != 'assistant' or message.get('done') is not False: + continue + + output = message.get('output') + if isinstance(output, list): + for item in output: + if item.get('type') == 'function_call' and item.get('status') in { + 'pending', + 'queued', + 'requires_approval', + }: + item['status'] = 'rejected' + item.pop('approved', None) + + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + message_id, + {'done': True, **({'output': output} if isinstance(output, list) else {})}, + touch=False, + ) + result = { + 'status': True, + 'message': 'Finalized pending approval message.', + } + + event_emitter = await get_event_emitter( + { + 'user_id': chat.user_id, + 'chat_id': chat_id, + 'message_id': message_id, + }, + update_db=False, + ) + if event_emitter: + await event_emitter({'type': 'chat:completion', 'data': {'done': True, 'output': output}}) + await event_emitter({'type': 'chat:tasks:cancel'}) + return result @@ -2113,6 +2242,7 @@ async def get_app_config(request: Request): 'automations.enable', 'notes.enable', 'chat.context_compaction.enable', + 'chat.tool_permissions.enable', 'web.search.enable', 'web.search.confirmation.enable', 'web.search.confirmation.content', @@ -2129,6 +2259,7 @@ async def get_app_config(request: Request): 'memories.enable', 'ui.default_models', 'ui.default_pinned_models', + 'ui.default_interface_settings', 'ui.prompt_suggestions', 'code_execution.engine', 'code_interpreter.engine', @@ -2171,6 +2302,11 @@ async def get_app_config(request: Request): 'enable_signup': config.get('ui.enable_signup'), 'enable_login_form': config.get('ui.enable_login_form'), 'enable_websocket': ENABLE_WEBSOCKET_SUPPORT, + **( + {'websocket_heartbeat_interval': WEBSOCKET_HEARTBEAT_INTERVAL} + if WEBSOCKET_HEARTBEAT_INTERVAL is not None + else {} + ), # --- Authenticated: only consumed by logged-in frontend --- **( { @@ -2189,6 +2325,7 @@ async def get_app_config(request: Request): 'enable_automations': config.get('automations.enable'), 'enable_notes': config.get('notes.enable'), 'enable_context_compaction': config.get('chat.context_compaction.enable'), + 'enable_tool_permissions': config.get('chat.tool_permissions.enable'), 'enable_web_search': config.get('web.search.enable'), 'enable_web_search_confirmation': config.get('web.search.confirmation.enable'), 'web_search_confirmation_content': config.get('web.search.confirmation.content'), @@ -2259,6 +2396,7 @@ async def get_app_config(request: Request): 'sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID, }, 'ui': { + 'default_interface_settings': config.get('ui.default_interface_settings'), 'pending_user_overlay_title': config.get('ui.pending_user_overlay_title'), 'pending_user_overlay_content': config.get('ui.pending_user_overlay_content'), 'response_watermark': config.get('ui.watermark'), @@ -2559,8 +2697,19 @@ async def register_client(request, client_id: str) -> bool: oauth_server_key, oauth_scope=oauth_scope, ) + except InvalidToken: + log.error( + 'OAuth client re-registration failed for %s: InvalidToken. ' + 'Stored OAuth client data is invalid; reconnect this tool server.', + client_id, + ) + return False except Exception as e: - log.error(f'OAuth client re-registration failed for {client_id}: {e}') + log.error( + 'OAuth client re-registration failed for %s: %s', + client_id, + f'{type(e).__name__}: {e}' if str(e) else type(e).__name__, + ) return False try: @@ -2582,7 +2731,7 @@ async def register_client(request, client_id: str) -> bool: **apply_connection_oauth_options(connection, oauth_client_info.model_dump(mode='json')) ) oauth_client_manager.add_client(client_id, oauth_client_info) - log.info(f'Re-registered OAuth client {client_id} for tool server') + log.info('Re-registered OAuth client %s for tool server', client_id) return True @@ -2626,7 +2775,7 @@ async def oauth_client_authorize( detail='OAuth client registration is still invalid after re-registration', ) - return await oauth_client_manager.handle_authorize(request, client_id=client_id) + return await oauth_client_manager.handle_authorize(request, client_id=client_id, user_id=user.id) @app.get('/oauth/clients/{client_id}/callback') @@ -2634,12 +2783,10 @@ async def oauth_client_callback( client_id: str, request: Request, response: Response, - user=Depends(get_verified_user), ): return await oauth_client_manager.handle_callback( request, client_id=client_id, - user_id=user.id if user else None, response=response, ) @@ -2688,6 +2835,10 @@ async def oauth_backchannel_logout( async def get_manifest_json(): external_pwa_manifest_url = getattr(app.state, 'EXTERNAL_PWA_MANIFEST_URL', None) if external_pwa_manifest_url: + # LICENSE covers this install-time Open WebUI branding surface, including + # names, logos, manifests, metadata, and surrounding UI. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. session = await get_session() async with session.get( external_pwa_manifest_url, @@ -2696,6 +2847,10 @@ async def get_manifest_json(): r.raise_for_status() return await r.json() else: + # LICENSE covers this generated Open WebUI install branding surface, + # including names, logos, manifests, metadata, and surrounding UI. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. return { 'name': app.state.WEBUI_NAME, 'short_name': app.state.WEBUI_NAME, @@ -2704,6 +2859,9 @@ async def get_manifest_json(): 'display': 'standalone', 'background_color': '#343541', 'icons': [ + # LICENSE covers this Open WebUI install icon. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. { 'src': '/static/logo.png', 'type': 'image/png', @@ -2728,6 +2886,9 @@ async def get_manifest_json(): @app.get('/opensearch.xml') async def get_opensearch_xml(): webui_url = await Config.get('webui.url') + # LICENSE covers this Open WebUI search identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. xml_content = rf""" {app.state.WEBUI_NAME} @@ -2745,7 +2906,7 @@ def _sync_db_ping() -> None: """Verify the database is reachable with a simple SELECT 1. Uses a raw connection from the engine pool instead of the thread-local - ScopedSession. This is necessary because CommitSessionMiddleware + ScopedSession. This is necessary because AppHTTPMiddleware deliberately skips healthcheck paths (/health, /ready, /health/db), so any ScopedSession opened on a healthcheck worker thread is never rolled back or removed. If the session ever enters an invalid state diff --git a/backend/open_webui/migrations/versions/1ce6ade7d93b_add_group_member_user_id_index.py b/backend/open_webui/migrations/versions/1ce6ade7d93b_add_group_member_user_id_index.py new file mode 100644 index 0000000000..a5a00b7b2a --- /dev/null +++ b/backend/open_webui/migrations/versions/1ce6ade7d93b_add_group_member_user_id_index.py @@ -0,0 +1,28 @@ +"""Add group_member user_id index + +Revision ID: 1ce6ade7d93b +Revises: f0bd01a18a3d +Create Date: 2026-07-31 03:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = '1ce6ade7d93b' +down_revision = 'f0bd01a18a3d' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + inspector = sa.inspect(conn) + existing_indexes = {idx['name'] for idx in inspector.get_indexes('group_member')} + + if 'ix_group_member_user_id_group_id' not in existing_indexes: + op.create_index('ix_group_member_user_id_group_id', 'group_member', ['user_id', 'group_id']) + + +def downgrade(): + op.drop_index('ix_group_member_user_id_group_id', table_name='group_member') diff --git a/backend/open_webui/migrations/versions/6d09d1bf1f23_repair_double_encoded_user_oauth.py b/backend/open_webui/migrations/versions/6d09d1bf1f23_repair_double_encoded_user_oauth.py new file mode 100644 index 0000000000..d1f8595059 --- /dev/null +++ b/backend/open_webui/migrations/versions/6d09d1bf1f23_repair_double_encoded_user_oauth.py @@ -0,0 +1,64 @@ +"""repair double encoded user oauth + +Revision ID: 6d09d1bf1f23 +Revises: 1ce6ade7d93b +Create Date: 2026-08-10 23:20:20.374826 + +""" + +import json +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import open_webui.internal.db + + +# revision identifiers, used by Alembic. +revision: str = '6d09d1bf1f23' +down_revision: Union[str, None] = '1ce6ade7d93b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_user = sa.table( + 'user', + sa.column('id', sa.Text), + sa.column('oauth', sa.JSON), +) + + +def _decode_json_object(value: str) -> dict | None: + try: + decoded = json.loads(value) + except Exception: + return None + return decoded if isinstance(decoded, dict) else None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + + if 'user' not in inspector.get_table_names(): + return + + user_columns = {c['name'] for c in inspector.get_columns('user')} + if 'oauth' not in user_columns: + return + + rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall() + + for uid, oauth in rows: + if not isinstance(oauth, str): + continue + + decoded = _decode_json_object(oauth) + if decoded is None: + continue + + conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth=decoded)) + + +def downgrade() -> None: + pass diff --git a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py index c3965dbd7c..1de7af315f 100644 --- a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py +++ b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py @@ -185,9 +185,7 @@ def upgrade() -> None: for uid, oauth_sub in rows: if oauth_sub: provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub) - conn.execute( - sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}})) - ) + conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth={provider: {'sub': sub}})) # ── Migrate api_key column → api_key table (only if old column still exists) if 'api_key' in user_columns: @@ -226,7 +224,7 @@ def downgrade() -> None: for uid, oauth in rows: try: - data = json.loads(oauth) + data = oauth if isinstance(oauth, dict) else json.loads(oauth) provider = list(data.keys())[0] sub = data[provider].get('sub') oauth_sub = f'{provider}@{sub}' diff --git a/backend/open_webui/migrations/versions/d4c1a8e37b62_add_chat_timer_at_and_chat_indexes.py b/backend/open_webui/migrations/versions/d4c1a8e37b62_add_chat_timer_at_and_chat_indexes.py new file mode 100644 index 0000000000..97b6c45011 --- /dev/null +++ b/backend/open_webui/migrations/versions/d4c1a8e37b62_add_chat_timer_at_and_chat_indexes.py @@ -0,0 +1,70 @@ +"""add chat timer_at and chat list, unread and timer indexes + +Revision ID: d4c1a8e37b62 +Revises: 6d09d1bf1f23 +Create Date: 2026-08-23 18:05:12.441907 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'd4c1a8e37b62' +down_revision: str | None = '6d09d1bf1f23' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column('chat', sa.Column('timer_at', sa.BigInteger(), nullable=True)) + op.create_index( + 'timer_at_idx', + 'chat', + ['timer_at'], + sqlite_where=sa.text('timer_at IS NOT NULL'), + postgresql_where=sa.text('timer_at IS NOT NULL'), + ) + + # Timers created before this migration carry their due time in meta only, and would never fire. + chat = sa.table( + 'chat', sa.column('id', sa.String), sa.column('meta', sa.JSON), sa.column('timer_at', sa.BigInteger) + ) + conn = op.get_bind() + pending = conn.execute( + sa.select(chat.c.id, chat.c.meta) + .where(chat.c.meta['type'].as_string() == 'timer') + .where(chat.c.meta['status'].as_string() == 'pending') + ).all() + for chat_id, meta in pending: + try: # imported chats can carry any meta, and a non-numeric due time must not abort the migration + due_at = int(meta.get('timer_at')) + except (TypeError, ValueError): + continue + conn.execute(chat.update().where(chat.c.id == chat_id).values(timer_at=due_at)) + + op.create_index('user_id_updated_at_id_idx', 'chat', ['user_id', sa.text('updated_at DESC'), 'id']) + op.create_index( + 'user_id_timer_at_idx', + 'chat', + ['user_id', 'timer_at'], + sqlite_where=sa.text('timer_at IS NOT NULL'), + postgresql_where=sa.text('timer_at IS NOT NULL'), + ) + op.create_index( + 'user_id_folder_unread_idx', + 'chat', + ['user_id', 'folder_id', 'archived', 'updated_at', 'last_read_at', 'id'], + ) + op.create_index('chat_message_chat_role_done_idx', 'chat_message', ['chat_id', 'role', 'done']) + + +def downgrade() -> None: + op.drop_index('chat_message_chat_role_done_idx', table_name='chat_message') + op.drop_index('user_id_folder_unread_idx', table_name='chat') + op.drop_index('user_id_timer_at_idx', table_name='chat') + op.drop_index('user_id_updated_at_id_idx', table_name='chat') + op.drop_index('timer_at_idx', table_name='chat') + op.drop_column('chat', 'timer_at') diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 7cca23546c..03a7ef7202 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -839,7 +839,8 @@ class AccessGrantsTable: ): """ Filter for items where user has read BUT NOT write access. - Public items are NOT considered read_only. + A public (user:*) read grant counts as read access, so publicly shared + read-only items are listed rather than being reachable only by direct link. Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself, so it remains synchronous. The caller is responsible for executing the query @@ -850,7 +851,6 @@ class AccessGrantsTable: from sqlalchemy import exists as sa_exists - # Has read grant (not public) read_grant_exists = ( select(AccessGrant.id) .where( @@ -858,6 +858,10 @@ class AccessGrantsTable: AccessGrant.resource_id == DocumentModel.id, AccessGrant.permission == 'read', or_( + and_( + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', + ), *( [ and_( @@ -884,7 +888,6 @@ class AccessGrantsTable: .exists() ) - # Does NOT have write grant write_grant_exists = ( select(AccessGrant.id) .where( @@ -892,6 +895,10 @@ class AccessGrantsTable: AccessGrant.resource_id == DocumentModel.id, AccessGrant.permission == 'write', or_( + and_( + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', + ), *( [ and_( @@ -918,21 +925,7 @@ class AccessGrantsTable: .exists() ) - # Is NOT public - public_grant_exists = ( - select(AccessGrant.id) - .where( - AccessGrant.resource_type == resource_type, - AccessGrant.resource_id == DocumentModel.id, - AccessGrant.permission == 'read', - AccessGrant.principal_type == 'user', - AccessGrant.principal_id == '*', - ) - .correlate(DocumentModel) - .exists() - ) - - conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists] + conditions = [read_grant_exists, ~write_grant_exists] # Not owner if user_id: diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index c0a7416cc6..11a906670b 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -1,9 +1,10 @@ import logging import time -from typing import Optional +from typing import Literal, Optional from uuid import uuid4 from open_webui.internal.db import Base, get_async_db_context +from open_webui.utils.misc import json_text_variants from pydantic import BaseModel, ConfigDict from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -64,11 +65,17 @@ class AutomationTerminalConfig(BaseModel): cwd: Optional[str] = None +class AutomationTarget(BaseModel): + type: Literal['chat', 'channel'] = 'chat' + channel_id: Optional[str] = None + + class AutomationData(BaseModel): prompt: str model_id: str rrule: str terminal: Optional[AutomationTerminalConfig] = None + target: Optional[AutomationTarget] = None class AutomationModel(BaseModel): @@ -179,16 +186,16 @@ class AutomationTable: async with get_async_db_context(db) as db: stmt = select(Automation).filter_by(user_id=user_id) - if folder_id is not None: - stmt = stmt.filter(Automation.folder_id == (folder_id or None)) + if folder_id: + stmt = stmt.filter(Automation.folder_id == folder_id) if query: - search = f'%{query}%' - # Search in name and prompt inside JSON data + # Search the name column and the prompt inside the JSON data. + data_text = cast(Automation.data, String) stmt = stmt.filter( or_( - Automation.name.ilike(search), - cast(Automation.data, String).ilike(search), + Automation.name.ilike(f'%{query}%'), + *(data_text.ilike(f'%{variant}%') for variant in json_text_variants(query)), ) ) diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index efdfd291ba..52470f1178 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -734,10 +734,10 @@ class CalendarEventTable: events = [] for event, tz in rows: model = CalendarEventModel.model_validate(event) - # Determine per-event alert window - alert_minutes = None - if model.meta and 'alert_minutes' in model.meta: - alert_minutes = model.meta['alert_minutes'] + # meta is user-writable and this poll is shared by every user. + alert_minutes = (model.meta or {}).get('alert_minutes') + if not isinstance(alert_minutes, (int, float)): + alert_minutes = None if alert_minutes is not None: if alert_minutes < 0: diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 95cc58cd32..c30e2347b9 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -1,4 +1,3 @@ -import json import secrets import time import uuid @@ -10,6 +9,7 @@ from open_webui.models.access_grants import ( AccessGrants, ) from open_webui.models.groups import Groups +from open_webui.models.users import User from open_webui.utils.validate import validate_profile_image_url from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy import ( @@ -438,17 +438,17 @@ class ChannelTable: match_count = func.sum( case( - (ChannelMember.user_id.in_(unique_user_ids), 1), + (User.id.in_(unique_user_ids), 1), else_=0, ) ) subquery = ( select(ChannelMember.channel_id) + .join(User, User.id == ChannelMember.user_id) .group_by(ChannelMember.channel_id) - # 1. Channel must have exactly len(user_ids) members - .having(func.count(ChannelMember.user_id) == len(unique_user_ids)) - # 2. All those members must be in unique_user_ids + # Match the exact set of accounts that still exist. + .having(func.count(User.id) == len(unique_user_ids)) .having(match_count == len(unique_user_ids)) .subquery() ) diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 8215cb69db..baa45ab603 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -1,4 +1,3 @@ -import json import time import uuid from collections import Counter @@ -169,6 +168,7 @@ class ChatMessage(Base): Index('chat_message_chat_parent_idx', 'chat_id', 'parent_id'), Index('chat_message_model_created_idx', 'model_id', 'created_at'), Index('chat_message_user_created_idx', 'user_id', 'created_at'), + Index('chat_message_chat_role_done_idx', 'chat_id', 'role', 'done'), # unfinished-assistant probe ) @@ -207,6 +207,66 @@ class ChatMessageModel(BaseModel): class ChatMessageTable: + @staticmethod + def _apply_message_data(message: ChatMessage, data: dict, now: int) -> None: + """Overwrite only the fields the payload carries.""" + if 'role' in data: + message.role = data['role'] + if 'parent_id' in data or 'parentId' in data: + message.parent_id = data.get('parent_id') or data.get('parentId') + if 'content' in data: + message.content = data.get('content') + if 'output' in data: + message.output = data.get('output') + if 'model_id' in data or 'model' in data: + message.model_id = data.get('model_id') or data.get('model') + if 'files' in data: + message.files = data.get('files') + if 'sources' in data: + message.sources = data.get('sources') + if 'embeds' in data: + message.embeds = data.get('embeds') + if 'meta' in data: + message.meta = data.get('meta') + if 'done' in data: + message.done = data['done'] + if 'status_history' in data or 'statusHistory' in data: + message.status_history = data.get('status_history') or data.get('statusHistory') + if 'error' in data: + message.error = data.get('error') + if 'context_summary' in data or 'contextSummary' in data: + message.context_summary = data.get('context_summary') or data.get('contextSummary') + + usage = get_usage(data) + if usage: + existing_usage = normalize_usage(message.usage) + message.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage) + message.updated_at = now + + @staticmethod + def _build_message(composite_id: str, chat_id: str, user_id: str, data: dict, now: int) -> ChatMessage: + return ChatMessage( + id=composite_id, + chat_id=chat_id, + user_id=user_id, + role=data.get('role', 'user'), + parent_id=data.get('parent_id') or data.get('parentId'), + content=data.get('content'), + output=data.get('output'), + model_id=data.get('model_id') or data.get('model'), + files=data.get('files'), + sources=data.get('sources'), + embeds=data.get('embeds'), + meta=data.get('meta'), + done=data.get('done', True), + status_history=data.get('status_history') or data.get('statusHistory'), + error=data.get('error'), + usage=get_usage(data), + context_summary=data.get('context_summary') or data.get('contextSummary'), + created_at=data.get('timestamp', now), + updated_at=now, + ) + async def upsert_message( self, message_id: str, @@ -218,76 +278,46 @@ class ChatMessageTable: """Insert or update a chat message.""" async with get_async_db_context(db) as db: now = int(time.time()) - timestamp = data.get('timestamp', now) - # Use composite ID: {chat_id}-{message_id} composite_id = f'{chat_id}-{message_id}' - existing = await db.get(ChatMessage, composite_id) - if existing: - # Update existing - if 'role' in data: - existing.role = data['role'] - if 'parent_id' in data or 'parentId' in data: - existing.parent_id = data.get('parent_id') or data.get('parentId') - if 'content' in data: - existing.content = data.get('content') - if 'output' in data: - existing.output = data.get('output') - if 'model_id' in data or 'model' in data: - existing.model_id = data.get('model_id') or data.get('model') - if 'files' in data: - existing.files = data.get('files') - if 'sources' in data: - existing.sources = data.get('sources') - if 'embeds' in data: - existing.embeds = data.get('embeds') - if 'meta' in data: - existing.meta = data.get('meta') - if 'done' in data: - existing.done = data.get('done', True) - if 'status_history' in data or 'statusHistory' in data: - existing.status_history = data.get('status_history') or data.get('statusHistory') - if 'error' in data: - existing.error = data.get('error') - if 'context_summary' in data or 'contextSummary' in data: - existing.context_summary = data.get('context_summary') or data.get('contextSummary') - # Extract and normalize usage - usage = get_usage(data) - if usage: - existing_usage = normalize_usage(existing.usage or {}) if existing.usage else {} - existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage) - existing.updated_at = now - await db.commit() - return ChatMessageModel.model_validate(existing) + message = await db.get(ChatMessage, composite_id) + if message: + self._apply_message_data(message, data, now) else: - # Insert new - # Extract and normalize usage - usage = get_usage(data) - message = ChatMessage( - id=composite_id, - chat_id=chat_id, - user_id=user_id, - role=data.get('role', 'user'), - parent_id=data.get('parent_id') or data.get('parentId'), - content=data.get('content'), - output=data.get('output'), - model_id=data.get('model_id') or data.get('model'), - files=data.get('files'), - sources=data.get('sources'), - embeds=data.get('embeds'), - meta=data.get('meta'), - done=data.get('done', True), - status_history=data.get('status_history') or data.get('statusHistory'), - error=data.get('error'), - usage=usage, - context_summary=data.get('context_summary') or data.get('contextSummary'), - created_at=timestamp, - updated_at=now, - ) + message = self._build_message(composite_id, chat_id, user_id, data, now) db.add(message) - await db.commit() - return ChatMessageModel.model_validate(message) + + await db.commit() + return ChatMessageModel.model_validate(message) + + async def upsert_messages( + self, + chat_id: str, + user_id: str, + messages: dict[str, dict], + db: AsyncSession | None = None, + ) -> None: + """Insert or update the given messages of one chat.""" + if not messages: + return + + async with get_async_db_context(db) as db: + now = int(time.time()) + result = await db.execute( + select(ChatMessage).filter(ChatMessage.id.in_([f'{chat_id}-{message_id}' for message_id in messages])) + ) + existing_by_id = {row.id: row for row in result.scalars().all()} + + for message_id, data in messages.items(): + composite_id = f'{chat_id}-{message_id}' + message = existing_by_id.get(composite_id) + if message: + self._apply_message_data(message, data, now) + else: + db.add(self._build_message(composite_id, chat_id, user_id, data, now)) + + await db.commit() async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]: async with get_async_db_context(db) as db: diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 26e9dd3fc1..ae31aeda03 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -2,13 +2,16 @@ from __future__ import annotations -import json import logging +import re import time import uuid +from typing import Any, Literal # local imports +from open_webui.env import ENABLE_ADMIN_CHAT_ACCESS from open_webui.internal.db import Base, JSONField, get_async_db_context +from open_webui.models.access_grants import AccessGrants from open_webui.models.automations import AutomationRun from open_webui.models.chat_messages import ChatMessage, ChatMessages from open_webui.models.folders import Folders @@ -41,6 +44,62 @@ from sqlalchemy.sql.expression import bindparam log = logging.getLogger(__name__) ACTIVE_CHAT_GAP_SECONDS = 30 * 60 +CHAT_SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:') + + +def chat_search_content_query(text: str) -> str: + words = sanitize_text_for_db(text).lower().strip().split() + return ' '.join(word for word in words if not word.startswith(CHAT_SEARCH_FILTER_PREFIXES)).strip() + + +def chat_search_terms(text: str) -> list[str]: + return list(dict.fromkeys(re.findall(r'[a-z0-9]+', text.lower()))) + + +def chat_search_message_content_match_sql(dialect_name: str, key: str) -> str: + if dialect_name == 'sqlite': + return f""" + ( + EXISTS ( + SELECT 1 + FROM json_each(Chat.chat, '$.history.messages') AS history_message + WHERE LOWER(history_message.value->>'content') LIKE '%' || :{key} || '%' + ) + OR EXISTS ( + SELECT 1 + FROM json_each(Chat.chat, '$.messages') AS legacy_message + WHERE LOWER(legacy_message.value->>'content') LIKE '%' || :{key} || '%' + ) + ) + """ + + if dialect_name == 'postgresql': + return f""" + ( + EXISTS ( + SELECT 1 + FROM chat_message AS message + WHERE message.chat_id = Chat.id + AND message.user_id = Chat.user_id + AND json_typeof(message.content) = 'string' + AND LOWER(message.content #>> '{{}}') LIKE '%' || :{key} || '%' + ) + OR EXISTS ( + SELECT 1 + FROM json_each(Chat.chat#>'{{history,messages}}') AS history_message + WHERE json_typeof(history_message.value->'content') = 'string' + AND LOWER(history_message.value->>'content') LIKE '%' || :{key} || '%' + ) + OR EXISTS ( + SELECT 1 + FROM json_array_elements(Chat.chat->'messages') AS legacy_message + WHERE json_typeof(legacy_message->'content') = 'string' + AND LOWER(legacy_message->>'content') LIKE '%' || :{key} || '%' + ) + ) + """ + + raise NotImplementedError(f'Unsupported dialect: {dialect_name}') def chat_list_order(sort_by: str = 'updated_at', sort_dir: str = 'desc', user_id: str | None = None): @@ -91,6 +150,7 @@ class Chat(Base): # database table mapping for chat entity current_message_id = Column(Text, nullable=True) last_read_at = Column(BigInteger, nullable=True) + timer_at = Column(BigInteger, nullable=True) # ns due time, set only while a timer chat waits to be claimed __table_args__ = ( # Performance indexes for common queries @@ -99,6 +159,23 @@ class Chat(Base): # database table mapping for chat entity Index('user_id_archived_idx', 'user_id', 'archived'), Index('updated_at_user_id_idx', 'updated_at', 'user_id'), Index('folder_id_user_id_idx', 'folder_id', 'user_id'), + Index('user_id_updated_at_id_idx', 'user_id', updated_at.desc(), 'id'), + Index( + 'timer_at_idx', + 'timer_at', + sqlite_where=text('timer_at IS NOT NULL'), + postgresql_where=text('timer_at IS NOT NULL'), + ), + # timer_at key column turns the IS NOT NULL into a seek, so this beats the plain user_id indexes + Index( + 'user_id_timer_at_idx', + 'user_id', + 'timer_at', + sqlite_where=text('timer_at IS NOT NULL'), + postgresql_where=text('timer_at IS NOT NULL'), + ), + # covering index: lets SQLite serve count_unread_by_folder_ids without reading chat rows + Index('user_id_folder_unread_idx', 'user_id', 'folder_id', 'archived', 'updated_at', 'last_read_at', 'id'), ) @@ -129,6 +206,7 @@ class ChatModel(BaseModel): current_message_id: str | None = None last_read_at: int | None = None + timer_at: int | None = None @field_validator('variables', mode='before') @classmethod @@ -342,6 +420,9 @@ class ChatTable: """ Clean a Chat SQLAlchemy model's title + chat JSON, and return True if anything changed. + + The message write paths (upsert/status/delete) rely on this + leaving the blob clean and sanitize only the data they add. """ changed = False @@ -361,6 +442,20 @@ class ChatTable: return changed + @staticmethod + def _last_descendant_id(messages: dict, message_id: str) -> str: + seen_ids = set() + while message_id in messages and message_id not in seen_ids: + seen_ids.add(message_id) + message = messages[message_id] + child_ids = message.get('childrenIds') if isinstance(message, dict) else [] + child_ids = child_ids if isinstance(child_ids, list) else [] + next_id = next((child_id for child_id in reversed(child_ids) if child_id in messages), None) + if not next_id: + break + message_id = next_id + return message_id + def _repair_chat_current_id(self, chat: dict) -> bool: history = chat.get('history') if not isinstance(history, dict): @@ -393,6 +488,12 @@ class ChatTable: and current_message.get('role') and not current_is_bad_leaf ): + if current_message.get('contextSummary') or current_message.get('context_summary'): + last_descendant_id = self._last_descendant_id(messages, current_id) + if last_descendant_id != current_id: + history['currentId'] = last_descendant_id + return True + return False latest_leaf_id = None @@ -421,6 +522,7 @@ class ChatTable: db: AsyncSession | None = None, *, internal_meta: dict | None = None, + timer_at: int | None = None, ) -> ChatModel | None: async with get_async_db_context(db) as session: chat = ChatModel( @@ -433,6 +535,7 @@ class ChatTable: 'chat': self._clean_null_bytes(form_data.chat), 'folder_id': form_data.folder_id, 'meta': internal_meta or {}, + 'timer_at': timer_at, 'variables': form_data.variables or {}, 'current_message_id': self.get_current_message_id(form_data.chat), 'created_at': int(time.time()), @@ -596,17 +699,29 @@ class ChatTable: *, touch: bool = True, ) -> ChatModel | None: - """Persist updated chat content, sanitizing null bytes.""" - try: # load the chat record for in-place mutation + """Patch top-level chat keys; history is merged so stale writers don't drop messages.""" + try: async with get_async_db_context(db) as session: - chat_item = await session.get(Chat, id) + chat_item = await session.get( + Chat, + id, + populate_existing=True, + with_for_update=session.bind.dialect.name == 'postgresql', + ) if chat_item is None: return None - chat_item.chat = self._clean_null_bytes(chat) - chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat' + stored = chat_item.chat or {} + updated = {**stored, **chat} + if 'history' in chat: + # The caller built its history from an earlier read; merge so messages saved since then survive. + updated['history'] = self.merge_history(stored.get('history'), chat['history']) + + updated = self._clean_null_bytes(updated) + chat_item.chat = updated + chat_item.title = updated.get('title', 'New Chat') if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')): - chat_item.current_message_id = self.get_current_message_id(chat) + chat_item.current_message_id = self.get_current_message_id(updated) if touch: chat_item.updated_at = int(time.time()) @@ -713,7 +828,12 @@ class ChatTable: async def update_chat_title_by_id(self, id: str, title: str) -> ChatModel | None: try: async with get_async_db_context() as session: - chat_item = await session.get(Chat, id) + chat_item = await session.get( + Chat, + id, + populate_existing=True, + with_for_update=session.bind.dialect.name == 'postgresql', + ) if chat_item is None: return None clean_title = self._clean_null_bytes(title) @@ -774,11 +894,12 @@ class ChatTable: def merge_history(existing_history: dict | None, incoming_history: dict | None) -> dict: existing = (existing_history or {}).get('messages') or {} incoming = (incoming_history or {}).get('messages') or {} - merged = {**existing, **incoming} - merged = {message_id: message for message_id, message in merged.items() if isinstance(message, dict)} + merged = { + message_id: {**message, 'childrenIds': []} + for message_id, message in {**existing, **incoming}.items() + if isinstance(message, dict) + } - for message in merged.values(): - message['childrenIds'] = [] for message_id, message in merged.items(): parent_id = message.get('parentId') if parent_id in merged: @@ -826,8 +947,10 @@ class ChatTable: if current_id is None else messages.get(current_id, {}).get('childrenIds', []) ) - while child_ids: + visited_ids = set() + while child_ids and child_ids[-1] not in visited_ids: current_id = child_ids[-1] + visited_ids.add(current_id) child_ids = messages.get(current_id, {}).get('childrenIds', []) history['currentId'] = current_id if current_id in messages else None return deleted_ids @@ -874,26 +997,22 @@ class ChatTable: 'role': role, 'timestamp': message.get('timestamp') or int(time.time()), } - - history['currentId'] = message_id + history['currentId'] = message_id return messages[message_id] async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None: """Write messages to the ``chat_message`` table so future lookups use the fast path. Errors are logged but never raised. """ - for message_id, message in messages.items(): - if not isinstance(message, dict) or not message.get('role'): - continue - try: - await ChatMessages.upsert_message( - message_id=message_id, - chat_id=chat_id, - user_id=user_id, - data=message, - ) - except Exception as e: - log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e) + writable = { + message_id: message + for message_id, message in messages.items() + if isinstance(message, dict) and message.get('role') + } + try: + await ChatMessages.upsert_messages(chat_id, user_id, writable) + except Exception as e: + log.warning('Backfill failed for chat %s: %s', chat_id, e) async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None: """Sync ``chat_message`` rows with the committed JSON blob. @@ -960,12 +1079,40 @@ class ChatTable: return history_messages async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> dict | None: + messages_map = await ChatMessages.get_messages_map_by_chat_id(id) + if messages_map and message_id in messages_map: + return messages_map[message_id] + chat = await self.get_chat_by_id(id) if chat is None: return None return chat.chat.get('history', {}).get('messages', {}).get(message_id, {}) + async def get_message_metadata( + self, + chat_id: str, + message_id: str, + metadata_key: Literal['files', 'sources', 'embeds'], + ) -> Any | None: + """Read one message metadata field without rebuilding the whole history.""" + async with get_async_db_context() as db: + # Read the column directly; some stored rows cannot be validated as full ChatMessageModel objects. + result = await db.execute( + select(getattr(ChatMessage, metadata_key)).where(ChatMessage.id == f'{chat_id}-{message_id}') + ) + metadata_row = result.first() + + if metadata_row is not None: + return metadata_row[0] + + chat = await self.get_chat_by_id(chat_id) + if chat is None: + return None + + message = chat.chat.get('history', {}).get('messages', {}).get(message_id, {}) + return message.get(metadata_key) + async def upsert_message_to_chat_by_id_and_message_id( self, id: str, message_id: str, message: dict, *, touch: bool = True ) -> ChatModel | None: @@ -974,13 +1121,17 @@ class ChatTable: if output_text: message['content'] = output_text - # Sanitize message content for null characters before upserting - if isinstance(message.get('content'), str): - message['content'] = sanitize_text_for_db(message['content']) + message = self._clean_null_bytes(message) + message_id = self._clean_null_bytes(message_id) try: async with get_async_db_context() as session: - chat_item = await session.get(Chat, id) + chat_item = await session.get( + Chat, + id, + populate_existing=True, + with_for_update=session.bind.dialect.name == 'postgresql', + ) if chat_item is None: return None @@ -991,10 +1142,9 @@ class ChatTable: history = chat.get('history', {}) saved_message = self.upsert_message_to_history(history, message_id, message) chat['history'] = history - clean_chat = self._clean_null_bytes(chat) - chat_item.chat = clean_chat - chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat' - chat_item.current_message_id = self.get_current_message_id(clean_chat) + chat_item.chat = chat # chat is a fresh dict when the column was empty + chat_item.title = chat.get('title', 'New Chat') + chat_item.current_message_id = self.get_current_message_id(chat) flag_modified(chat_item, 'chat') if touch: @@ -1022,7 +1172,12 @@ class ChatTable: async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None: try: async with get_async_db_context() as session: - chat_item = await session.get(Chat, id) + chat_item = await session.get( + Chat, + id, + populate_existing=True, + with_for_update=session.bind.dialect.name == 'postgresql', + ) if chat_item is None: return None @@ -1033,22 +1188,18 @@ class ChatTable: history = chat.get('history', {}) deleted_ids = self.delete_message_from_history(history, message_id) if not deleted_ids: - clean_chat = self._clean_null_bytes(chat) - chat_item.chat = clean_chat - chat_item.title = ( - self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat' - ) - chat_item.current_message_id = self.get_current_message_id(clean_chat) + chat_item.chat = chat + chat_item.title = chat.get('title', 'New Chat') + chat_item.current_message_id = self.get_current_message_id(chat) flag_modified(chat_item, 'chat') await session.commit() return ChatModel.model_validate(chat_item) messages = history.get('messages') or {} chat['history'] = history - clean_chat = self._clean_null_bytes(chat) - chat_item.chat = clean_chat - chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat' - chat_item.current_message_id = self.get_current_message_id(clean_chat) + chat_item.chat = chat + chat_item.title = chat.get('title', 'New Chat') + chat_item.current_message_id = self.get_current_message_id(chat) flag_modified(chat_item, 'chat') chat_item.updated_at = int(time.time()) await session.commit() @@ -1066,8 +1217,14 @@ class ChatTable: self, id: str, message_id: str, status: dict ) -> ChatModel | None: try: + status = self._clean_null_bytes(status) async with get_async_db_context() as session: - chat_item = await session.get(Chat, id) + chat_item = await session.get( + Chat, + id, + populate_existing=True, + with_for_update=session.bind.dialect.name == 'postgresql', + ) if chat_item is None: return None @@ -1082,10 +1239,9 @@ class ChatTable: history['messages'][message_id]['statusHistory'] = status_history chat['history'] = history - clean_chat = self._clean_null_bytes(chat) - chat_item.chat = clean_chat - chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat' - chat_item.current_message_id = self.get_current_message_id(clean_chat) + chat_item.chat = chat + chat_item.title = chat.get('title', 'New Chat') + chat_item.current_message_id = self.get_current_message_id(chat) flag_modified(chat_item, 'chat') await session.commit() @@ -1093,13 +1249,20 @@ class ChatTable: except Exception: return None - async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]: + async def add_message_files_by_id_and_message_id( + self, id: str, message_id: str, files: list[dict] + ) -> list[dict] | None: async with get_async_db_context() as session: - chat = await self.get_chat_by_id(id, db=session) - if chat is None: + chat_item = await session.get( + Chat, + id, + populate_existing=True, + with_for_update=session.bind.dialect.name == 'postgresql', + ) + if chat_item is None: return None - chat = chat.chat + chat = chat_item.chat or {} history = chat.get('history', {}) message_files = [] @@ -1109,8 +1272,14 @@ class ChatTable: message_files = message_files + files history['messages'][message_id]['files'] = message_files + # Written here rather than through update_chat_by_id: with session sharing off that opens a second + # connection, which then blocks on the lock this one holds. chat['history'] = history - await self.update_chat_by_id(id, chat, db=session) + chat_item.chat = self._clean_null_bytes(chat) + # History was mutated in place, so the new blob compares equal to the loaded one. + flag_modified(chat_item, 'chat') + chat_item.updated_at = int(time.time()) + await session.commit() return message_files async def insert_shared_chat_by_chat_id(self, chat_id: str, db: AsyncSession | None = None) -> ChatModel | None: @@ -1508,6 +1677,7 @@ class ChatTable: repaired_history = self._repair_chat_current_id(chat_item.chat or {}) if repaired_history: + chat_item.current_message_id = self.get_current_message_id(chat_item.chat) flag_modified(chat_item, 'chat') if self._sanitize_chat_row(chat_item) or repaired_history: await session.commit() @@ -1549,6 +1719,7 @@ class ChatTable: repaired_history = self._repair_chat_current_id(chat.chat or {}) if repaired_history: + chat.current_message_id = self.get_current_message_id(chat.chat) flag_modified(chat, 'chat') if self._sanitize_chat_row(chat) or repaired_history: await session.commit() @@ -1557,6 +1728,41 @@ class ChatTable: except Exception: return None + async def get_chat_by_id_for_user( + self, + id: str, + user, + db: AsyncSession | None = None, + ) -> ChatModel | None: + chat = await self.get_chat_by_id_and_user_id(id, user.id, db=db) + if chat: + return chat + + chat = await self.get_chat_by_id(id, db=db) + if not chat: + return None + + if user.role == 'admin' and (ENABLE_ADMIN_CHAT_ACCESS or is_internal_chat(chat.meta)): + return chat + + if await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=id, + permission='read', + db=db, + ): + return chat + + if chat.folder_id: + from open_webui.utils.access_control.folders import has_folder_access + + folder = await Folders.get_folder_by_id(chat.folder_id, db=db) + if folder and await has_folder_access(user.id, folder, 'read', db): + return chat + + return None + async def is_chat_owner(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool: """ Lightweight ownership check — uses EXISTS subquery instead of loading @@ -1732,7 +1938,7 @@ class ChatTable: return [ChatModel.model_validate(chat) for chat in result.scalars().all()] # search user conversations - async def get_chats_by_user_id_and_search_text( + async def get_chats_by_user_id_and_search_text( # noqa: C901 self, user_id: str, search_text: str, @@ -1751,7 +1957,7 @@ class ChatTable: user_id, include_archived, filter={}, skip=skip, limit=limit, db=db ) - search_text_words = search_text.split(' ') + search_text_words = search_text.split() # search_text might contain 'tag:tag_name' format so we need to extract the tag_name tag_ids = [ @@ -1783,19 +1989,10 @@ class ChatTable: elif 'shared:false' in search_text_words: is_shared = False - search_text_words = [ - word - for word in search_text_words - if ( - not word.startswith('tag:') - and not word.startswith('folder:') - and not word.startswith('pinned:') - and not word.startswith('archived:') - and not word.startswith('shared:') - ) - ] + search_text_words = [word for word in search_text_words if not word.startswith(CHAT_SEARCH_FILTER_PREFIXES)] - search_text = ' '.join(search_text_words) + phrase_query = ' '.join(search_text_words).strip() + search_terms = chat_search_terms(phrase_query) async with get_async_db_context(db) as session: stmt = select(Chat).filter(Chat.user_id == user_id) @@ -1818,27 +2015,43 @@ class ChatTable: if folder_ids: stmt = stmt.filter(Chat.folder_id.in_(folder_ids)) - stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) - # Check if the database dialect is either 'sqlite' or 'postgresql' bind = await session.connection() dialect_name = bind.dialect.name - if dialect_name == 'sqlite': - # SQLite case: using JSON1 extension for JSON searching - sqlite_content_sql = ( - 'EXISTS (' - ' SELECT 1 ' - " FROM json_each(Chat.chat, '$.messages') AS message " - " WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'" - ')' + + search_params = {} + exact_match_clause = None + if phrase_query: + exact_match_clause = or_( + Chat.title.ilike(bindparam('phrase_title_key')), + text(chat_search_message_content_match_sql(dialect_name, 'phrase_content_key')), ) - sqlite_content_clause = text(sqlite_content_sql) - stmt = stmt.filter( - or_(Chat.title.ilike(bindparam('title_key')), sqlite_content_clause).params( - title_key=f'%{search_text}%', content_key=search_text - ) + search_params.update( + { + 'phrase_title_key': f'%{phrase_query}%', + 'phrase_content_key': phrase_query, + } ) + term_clauses = [] + for term_idx, term in enumerate(search_terms): + title_key = f'term_title_key_{term_idx}' + content_key = f'term_content_key_{term_idx}' + term_clauses.append( + or_( + Chat.title.ilike(bindparam(title_key)), + text(chat_search_message_content_match_sql(dialect_name, content_key)), + ) + ) + search_params[title_key] = f'%{term}%' + search_params[content_key] = term + + if term_clauses: + stmt = stmt.filter(or_(exact_match_clause, and_(*term_clauses))) + else: + stmt = stmt.filter(exact_match_clause) + + if dialect_name == 'sqlite': # Check if there are any tags to filter if 'none' in tag_ids: stmt = stmt.filter( @@ -1872,38 +2085,6 @@ class ChatTable: # Safety filter: title must not contain actual null bytes stmt = stmt.filter(text("Chat.title::text NOT LIKE '%\\x00%'")) - postgres_content_sql = """ - EXISTS ( - SELECT 1 - FROM chat_message AS message - WHERE message.chat_id = Chat.id - AND message.user_id = Chat.user_id - AND json_typeof(message.content) = 'string' - AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%' - ) - OR EXISTS ( - SELECT 1 - FROM json_each(Chat.chat#>'{history,messages}') AS history_message - WHERE json_typeof(history_message.value->'content') = 'string' - AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%' - ) - OR EXISTS ( - SELECT 1 - FROM json_array_elements(Chat.chat->'messages') AS legacy_message - WHERE json_typeof(legacy_message->'content') = 'string' - AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%' - ) - """ - - postgres_content_clause = text(postgres_content_sql) - - stmt = stmt.filter( - or_( - Chat.title.ilike(bindparam('title_key')), - postgres_content_clause, - ) - ).params(title_key=f'%{search_text}%', content_key=search_text.lower()) - if 'none' in tag_ids: stmt = stmt.filter( text(""" @@ -1931,12 +2112,20 @@ class ChatTable: else: raise NotImplementedError(f'Unsupported dialect: {dialect_name}') + if exact_match_clause is not None: + stmt = stmt.order_by(case((exact_match_clause, 0), else_=1), Chat.updated_at.desc(), Chat.id) + else: + stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) + + if search_params: + stmt = stmt.params(**search_params) + # Perform pagination at the SQL level stmt = stmt.offset(skip).limit(limit) result = await session.execute(stmt) all_chats = result.scalars().all() - log.info(f'The number of chats: {len(all_chats)}') + log.info('The number of chats: %s', len(all_chats)) # Validate and return chats return [ChatModel.model_validate(chat) for chat in all_chats] @@ -2101,7 +2290,7 @@ class ChatTable: bind = await session.connection() dialect_name = bind.dialect.name - log.info(f'DB dialect name: {dialect_name}') + log.info('DB dialect name: %s', dialect_name) if dialect_name == 'sqlite': stmt = stmt.filter( text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)") @@ -2228,7 +2417,7 @@ class ChatTable: result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True))) count = result.scalar() - log.info(f"Count of chats for folder '{folder_id}': {count}") + log.info("Count of chats for folder '%s': %s", folder_id, count) return count async def count_chats_by_folder_ids_and_user_id( @@ -2242,7 +2431,7 @@ class ChatTable: result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True))) count = result.scalar() - log.info(f"Count of chats for folders '{folder_ids}': {count}") + log.info("Count of chats for folders '%s': %s", folder_ids, count) return count async def delete_tag_by_id_and_user_id_and_tag_name( diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py index 6081214b4c..61b41f37cf 100644 --- a/backend/open_webui/models/config.py +++ b/backend/open_webui/models/config.py @@ -32,6 +32,8 @@ DICT_CONFIG_KEY_ALIASES = { 'audio.tts.openai.params': ('AUDIO_TTS_OPENAI_PARAMS',), 'models.default_metadata': ('DEFAULT_MODEL_METADATA',), 'models.default_params': ('DEFAULT_MODEL_PARAMS',), + 'task.model.params': ('TASK_MODEL_PARAMS',), + 'ui.default_interface_settings': ('DEFAULT_INTERFACE_SETTINGS',), 'user.permissions': ('USER_PERMISSIONS',), } DICT_CONFIG_KEYS = tuple(DICT_CONFIG_KEY_ALIASES) @@ -298,14 +300,16 @@ class Config(Base): ) @staticmethod - async def repair_flattened_dict_configs() -> None: - """Reassemble dict config values flattened by the per-key migration.""" + async def repair_config_rows() -> None: + """Repair known legacy config row shapes.""" if not Config.PERSISTENT_ENABLED: return async with get_async_db() as db: repaired_keys: list[str] = [] orphan_keys: list[str] = [] + default_model_keys: list[str] = [] + now = int(time.time()) for config_key, aliases in DICT_CONFIG_KEY_ALIASES.items(): prefixes = (config_key, *aliases) @@ -353,14 +357,26 @@ class Config(Base): if existing: existing.value = repaired - existing.updated_at = int(time.time()) + existing.updated_at = now else: - db.add(Config(key=config_key, value=repaired, updated_at=int(time.time()))) + db.add(Config(key=config_key, value=repaired, updated_at=now)) repaired_keys.append(config_key) if orphan_keys: await db.execute(delete(Config).where(Config.key.in_(orphan_keys))) - if repaired_keys or orphan_keys: + for key in ('ui.default_models', 'ui.default_pinned_models'): + row = await db.get(Config, key) + if not row or not isinstance(row.value, list): + continue + + row.value = ','.join(model_id for model_id in (str(item).strip() for item in row.value) if model_id) + row.updated_at = now + default_model_keys.append(key) + + if repaired_keys or orphan_keys or default_model_keys: await db.commit() - log.info('Repaired flattened dict config rows for %s', ', '.join(repaired_keys)) + if repaired_keys or orphan_keys: + log.info('Repaired flattened dict config rows for %s', ', '.join(repaired_keys)) + if default_model_keys: + log.info('Repaired default model config rows for %s', ', '.join(default_model_keys)) diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 11d5dd5427..1db732ed7e 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -157,6 +157,11 @@ class FolderTable: except Exception: return None + async def get_folders_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> list[FolderModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter(Folder.id.in_(ids)).order_by(Folder.updated_at.desc())) + return [FolderModel.model_validate(folder) for folder in result.scalars().all()] + async def get_shared_folder_ids_for_user( self, user_id: str, user_group_ids: set[str], db: Optional[AsyncSession] = None ) -> dict[str, str]: @@ -197,10 +202,14 @@ class FolderTable: try: async with get_async_db_context(db) as db: folders = [] + seen_ids = {id} async def get_children(folder): children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) for child in children: + if child.id in seen_ids: + continue + seen_ids.add(child.id) await get_children(child) folders.append(child) @@ -230,7 +239,9 @@ class FolderTable: async with get_async_db_context(db) as db: # Check if folder exists result = await db.execute( - select(Folder).filter_by(parent_id=parent_id, user_id=user_id).filter(Folder.name.ilike(name)) + select(Folder) + .filter_by(parent_id=parent_id, user_id=user_id) + .filter(func.lower(Folder.name) == func.lower(name)) ) folder = result.scalars().first() @@ -246,7 +257,9 @@ class FolderTable: self, parent_id: Optional[str], user_id: str, db: Optional[AsyncSession] = None ) -> list[FolderModel]: async with get_async_db_context(db) as db: - result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id)) + result = await db.execute( + select(Folder).filter_by(parent_id=parent_id, user_id=user_id).order_by(Folder.updated_at.desc()) + ) return [FolderModel.model_validate(folder) for folder in result.scalars().all()] async def get_folder_ids_by_id_and_user_id_in_subtree( @@ -258,15 +271,17 @@ class FolderTable: if not folder: return [] - folder_ids = [folder.id] + folder_ids = {folder.id} folders = [FolderModel.model_validate(folder)] while folders: current_folder = folders.pop() children = await self.get_folders_by_parent_id_and_user_id(current_folder.id, user_id, db=db) - folder_ids.extend(child.id for child in children) - folders.extend(children) + for child in children: + if child.id not in folder_ids: + folder_ids.add(child.id) + folders.append(child) - return folder_ids + return list(folder_ids) async def update_folder_parent_id_by_id_and_user_id( self, @@ -376,11 +391,15 @@ class FolderTable: return folder_ids folder_ids.append(folder.id) + seen_ids = {folder.id} # Delete all children folders async def delete_children(folder): folder_children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) for folder_child in folder_children: + if folder_child.id in seen_ids: + continue + seen_ids.add(folder_child.id) await delete_children(folder_child) folder_ids.append(folder_child.id) diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index 66785794a7..611afe576d 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -1,4 +1,3 @@ -import json import logging import time import uuid @@ -13,6 +12,7 @@ from sqlalchemy import ( BigInteger, Column, ForeignKey, + Index, String, Text, and_, @@ -72,6 +72,8 @@ class GroupModel(BaseModel): class GroupMember(Base): __tablename__ = 'group_member' + # The table's (group_id, user_id) unique constraint cannot serve user_id lookups. + __table_args__ = (Index('ix_group_member_user_id_group_id', 'user_id', 'group_id'),) id = Column(Text, unique=True, primary_key=True) group_id = Column( diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 52a78c46c1..848232d6f2 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -1,4 +1,3 @@ -import json import logging import time import uuid @@ -468,14 +467,22 @@ class KnowledgeTable: print('search_knowledge_files error:', e) return KnowledgeFileListResponse(items=[], total=0) - async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool: + async def check_access_by_user_id( + self, + id, + user_id, + permission='write', + db: Optional[AsyncSession] = None, + user_group_ids: set[str] | None = None, + ) -> bool: knowledge = await self.get_knowledge_by_id(id, db=db) if not knowledge: return False if knowledge.user_id == user_id: return True - user_groups = await Groups.get_groups_by_member_id(user_id, db=db) - user_group_ids = {group.id for group in user_groups} + if user_group_ids is None: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} return await AccessGrants.has_access( user_id=user_id, resource_type='knowledge', @@ -485,24 +492,6 @@ class KnowledgeTable: db=db, ) - async def get_knowledge_bases_by_user_id( - self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None - ) -> list[KnowledgeUserModel]: - knowledge_bases = await self.get_knowledge_bases(db=db) - user_groups = await Groups.get_groups_by_member_id(user_id, db=db) - user_group_ids = {group.id for group in user_groups} - - # One grants query for all non-owned knowledge bases instead of one each - accessible_ids = await AccessGrants.get_accessible_resource_ids( - user_id=user_id, - resource_type='knowledge', - resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id], - permission=permission, - user_group_ids=user_group_ids, - db=db, - ) - return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids] - async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]: try: async with get_async_db_context(db) as db: @@ -512,29 +501,6 @@ class KnowledgeTable: except Exception: return None - async def get_knowledge_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[AsyncSession] = None - ) -> Optional[KnowledgeModel]: - knowledge = await self.get_knowledge_by_id(id, db=db) - if not knowledge: - return None - - if knowledge.user_id == user_id: - return knowledge - - user_groups = await Groups.get_groups_by_member_id(user_id, db=db) - user_group_ids = {group.id for group in user_groups} - if await AccessGrants.has_access( - user_id=user_id, - resource_type='knowledge', - resource_id=knowledge.id, - permission='write', - user_group_ids=user_group_ids, - db=db, - ): - return knowledge - return None - async def get_knowledges_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[KnowledgeModel]: try: async with get_async_db_context(db) as db: @@ -809,25 +775,6 @@ class KnowledgeTable: log.exception(e) return None - async def update_knowledge_data_by_id( - self, id: str, data: dict, db: Optional[AsyncSession] = None - ) -> Optional[KnowledgeModel]: - try: - async with get_async_db_context(db) as db: - await db.execute( - update(Knowledge) - .filter_by(id=id) - .values( - data=data, - updated_at=int(time.time()), - ) - ) - await db.commit() - return await self.get_knowledge_by_id(id=id, db=db) - except Exception as e: - log.exception(e) - return None - async def update_knowledge_meta_by_id( self, id: str, meta: dict, db: Optional[AsyncSession] = None ) -> Optional[KnowledgeModel]: diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index dc43dfe1e9..82ac3336dd 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -1,4 +1,3 @@ -import json import time import uuid from typing import Optional diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 839e47c6d3..0f3b731ea4 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -1,19 +1,18 @@ from __future__ import annotations -import json import logging import time from copy import deepcopy -from typing import Any, Optional +from typing import Any from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, UserResponse, Users +from open_webui.utils.misc import json_text_variants from open_webui.utils.validate import validate_profile_image_url from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update -from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -23,6 +22,18 @@ log = logging.getLogger(__name__) _warned_profile_urls: set[str] = set() +def normalize_model_tags(tags: Any) -> list[dict[str, str]]: + if not isinstance(tags, list): + return [] + + normalized = [] + for tag in tags: + name = tag.get('name') if isinstance(tag, dict) else tag + if isinstance(name, str) and name.strip(): + normalized.append({'name': name.strip()}) + return normalized + + def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any: """Drop duplicated extracted text from ModelMeta.knowledge.""" if not isinstance(knowledge, list): @@ -99,15 +110,7 @@ class ModelMeta(BaseModel): @classmethod def normalize_tags(cls, data): if isinstance(data, dict) and 'tags' in data: - raw_tags = data['tags'] - if isinstance(raw_tags, list): - normalized = [] - for tag in raw_tags: - if isinstance(tag, str): - normalized.append({'name': tag}) - elif isinstance(tag, dict) and 'name' in tag: - normalized.append(tag) - data['tags'] = normalized + data['tags'] = normalize_model_tags(data['tags']) return data @@ -244,9 +247,21 @@ class ModelsTable: log.error('Skipping model %r during get_all_models due to error: %s', model.id, exc) return models - async def get_models(self, db: AsyncSession | None = None) -> list[ModelUserResponse]: + async def get_models( + self, writable_by_user_id: str | None = None, db: AsyncSession | None = None + ) -> list[ModelUserResponse]: async with get_async_db_context(db) as db: - result = await db.execute(select(Model).filter(Model.base_model_id != None)) + stmt = select(Model).filter(Model.base_model_id != None) + + if writable_by_user_id: + user_group_ids = { + group.id for group in await Groups.get_groups_by_member_id(writable_by_user_id, db=db) + } + stmt = self._has_permission( + db, stmt, {'user_id': writable_by_user_id, 'group_ids': user_group_ids}, permission='write' + ) + + result = await db.execute(stmt) all_models = result.scalars().all() user_ids = list(set(model.user_id for model in all_models)) @@ -275,6 +290,24 @@ class ModelsTable: ) return models + async def get_model_owners_attaching_file(self, file_id: str, db: AsyncSession | None = None) -> dict[str, str]: + """Map of model id to owner id for workspace models whose knowledge attaches this file.""" + async with get_async_db_context(db) as db: + # File ids are server-generated uuids, so the text match can only over-match. + result = await db.execute( + select(Model.id, Model.user_id, Model.meta).filter( + Model.base_model_id.is_not(None), cast(Model.meta, String).like(f'%"{file_id}"%') + ) + ) + return { + model_id: user_id + for model_id, user_id, meta in result.all() + if any( + isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file_id + for item in meta.get('knowledge') or [] + ) + } + @staticmethod def _meta_has_tag(meta: dict | None, tag: str) -> bool: if not meta: @@ -301,28 +334,6 @@ class ModelsTable: for model in all_models ] - async def get_models_by_user_id( - self, - user_id: str, - permission: str = 'write', - db: AsyncSession | None = None, - user_group_ids: set[str] | None = None, - ) -> list[ModelUserResponse]: - models = await self.get_models(db=db) - if user_group_ids is None: - user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)} - - # One grants query for all non-owned models instead of one per model - accessible_ids = await AccessGrants.get_accessible_resource_ids( - user_id=user_id, - resource_type='model', - resource_ids=[model.id for model in models if model.user_id != user_id], - permission=permission, - user_group_ids=user_group_ids, - db=db, - ) - return [model for model in models if model.user_id == user_id or model.id in accessible_ids] - def _has_permission(self, db, query, filter: dict, permission: str = 'read'): return AccessGrants.has_permission_filter( db=db, @@ -374,20 +385,14 @@ class ModelsTable: tag = filter.get('tag') if tag: - # SQLite stores JSON text via json.dumps(ensure_ascii=True), - # so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB - # stores literal Unicode. Use the right pattern for each. - if db.bind.dialect.name == 'sqlite': - if tag.isascii(): - meta_text = func.lower(cast(Model.meta, String)) - pattern = f'%{json.dumps(tag.lower())}%' - else: - meta_text = cast(Model.meta, String) - pattern = f'%{json.dumps(tag)}%' + if db.bind.dialect.name == 'sqlite' and not tag.isascii(): + # SQLite's LOWER() is ASCII-only, so match non-ASCII tags exact-case. + meta_text = cast(Model.meta, String) + variants = json_text_variants(tag) else: meta_text = func.lower(cast(Model.meta, String)) - pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%' - stmt = stmt.filter(meta_text.like(pattern)) + variants = json_text_variants(tag.lower()) + stmt = stmt.filter(or_(*(meta_text.like(f'%"{variant}"%') for variant in variants))) order_by = filter.get('order_by') direction = filter.get('direction') @@ -605,25 +610,16 @@ class ModelsTable: # Update or insert models for model in models: + model_data = { + **model.model_dump(exclude={'access_grants'}), + 'user_id': user_id, + 'updated_at': int(time.time()), + } + if model.id in existing_ids: - await db.execute( - update(Model) - .filter_by(id=model.id) - .values( - **model.model_dump(exclude={'access_grants'}), - user_id=user_id, - updated_at=int(time.time()), - ) - ) + await db.execute(update(Model).filter_by(id=model.id).values(**model_data)) else: - new_model = Model( - **{ - **model.model_dump(exclude={'access_grants'}), - 'user_id': user_id, - 'updated_at': int(time.time()), - } - ) - db.add(new_model) + db.add(Model(**model_data)) await AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db) # Remove models that are no longer present diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 8d8eb2414f..e9c06021e1 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -1,4 +1,3 @@ -import json import time import uuid from functools import lru_cache @@ -8,7 +7,8 @@ from open_webui.internal.db import Base, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, UserResponse, Users -from pydantic import BaseModel, ConfigDict, Field +from open_webui.utils.json_codec import JSONCodec +from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy import JSON, BigInteger, Boolean, Column, ForeignKey, Text, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -31,6 +31,32 @@ class Note(Base): updated_at = Column(BigInteger) +def sanitize_note_data(data: Optional[dict]) -> Optional[dict]: + """Sanitize malformed note.data so content.md is always markdown text.""" + if data is None: + return None + if not isinstance(data, dict): + return {'content': {'md': str(data)}} + + content = data.get('content') + if not isinstance(content, dict) or 'md' not in content or isinstance(content.get('md'), str): + return data + + md = content.get('md') if content.get('md') is not None else '' + if isinstance(md, (dict, list)): + md = f'```json\n{JSONCodec.dumps(md, indent=2, ensure_ascii=False)}\n```' + else: + md = str(md) + + return { + **data, + 'content': { + **content, + 'md': md, + }, + } + + class NoteModel(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -47,6 +73,11 @@ class NoteModel(BaseModel): created_at: int # timestamp in epoch updated_at: int # timestamp in epoch + @field_validator('data', mode='before') + @classmethod + def sanitize_data(cls, data): + return sanitize_note_data(data) + class PinnedNote(Base): __tablename__ = 'pinned_note' @@ -68,6 +99,11 @@ class NoteForm(BaseModel): meta: Optional[dict] = None access_grants: Optional[list[dict]] = None + @field_validator('data', mode='before') + @classmethod + def sanitize_data(cls, data): + return sanitize_note_data(data) + class NoteUpdateForm(BaseModel): title: Optional[str] = None @@ -75,6 +111,11 @@ class NoteUpdateForm(BaseModel): meta: Optional[dict] = None access_grants: Optional[list[dict]] = None + @field_validator('data', mode='before') + @classmethod + def sanitize_data(cls, data): + return sanitize_note_data(data) + class NoteUserResponse(NoteModel): user: Optional[UserResponse] = None @@ -305,6 +346,7 @@ class NoteTable: return None form_data = form_data.model_dump(exclude_unset=True) + note.data = sanitize_note_data(note.data) or {} if 'title' in form_data: note.title = form_data['title'] diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index 325f0f5f51..25a50ced55 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -1,6 +1,5 @@ import base64 import hashlib -import json import logging import time import uuid @@ -9,6 +8,7 @@ from typing import List, Optional from cryptography.fernet import Fernet from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY from open_webui.internal.db import Base, get_async_db_context +from open_webui.utils.json_codec import JSONCodec from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, Index, String, Text, delete, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -85,7 +85,7 @@ class OAuthSessionTable: def _encrypt_token(self, token) -> str: """Encrypt OAuth tokens for storage""" try: - token_json = json.dumps(token) + token_json = JSONCodec.dumps(token) encrypted = self.fernet.encrypt(token_json.encode()).decode() return encrypted except Exception as e: @@ -96,7 +96,7 @@ class OAuthSessionTable: """Decrypt OAuth tokens from storage""" try: decrypted = self.fernet.decrypt(token.encode()).decode() - return json.loads(decrypted) + return JSONCodec.loads(decrypted) except Exception as e: log.error(f'Error decrypting tokens: {type(e).__name__}: {e}') raise diff --git a/backend/open_webui/models/prompt_history.py b/backend/open_webui/models/prompt_history.py index 947d33a133..074bb5d039 100644 --- a/backend/open_webui/models/prompt_history.py +++ b/backend/open_webui/models/prompt_history.py @@ -1,7 +1,6 @@ """Prompt history model for version tracking.""" import difflib -import json import time import uuid from typing import Optional diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index f4f4187f17..54e67ac2f4 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import logging import time import uuid @@ -15,6 +14,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.prompt_history import PromptHistories from open_webui.models.users import User, UserModel, UserResponse, Users +from open_webui.utils.misc import json_text_variants from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, text, update from sqlalchemy.ext.asyncio import AsyncSession @@ -342,9 +342,10 @@ class PromptsTable: 'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)' ) else: - # Fallback: LIKE on serialised JSON text (ASCII-safe only) - tag_clause = func.lower(cast(Prompt.tags, String)).like( - f'%{json.dumps(tag_lower, ensure_ascii=False)}%' + # Fallback for dialects with no JSON array function: LIKE on the text. + tags_text = func.lower(cast(Prompt.tags, String)) + tag_clause = or_( + *(tags_text.like(f'%"{variant}"%') for variant in json_text_variants(tag_lower)) ) tag_lower = None diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index b4a64fd02d..cff4778394 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -163,9 +163,30 @@ class SkillsTable: except Exception: return None - async def get_skills(self, db: Optional[AsyncSession] = None) -> list[SkillUserModel]: + async def get_skills( + self, + user_id: str | None = None, + ids: list[str] | None = None, + db: AsyncSession | None = None, + ) -> list[SkillUserModel]: async with get_async_db_context(db) as db: - result = await db.execute(select(Skill).order_by(Skill.updated_at.desc())) + stmt = select(Skill).order_by(Skill.updated_at.desc()) + + if ids is not None: + stmt = stmt.filter(Skill.id.in_(ids)) + + if user_id is not None: + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)} + stmt = AccessGrants.has_permission_filter( + db=db, + query=stmt, + DocumentModel=Skill, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='skill', + permission='read', + ) + + result = await db.execute(stmt) all_skills = result.scalars().all() user_ids = list(set(skill.user_id for skill in all_skills)) @@ -194,28 +215,6 @@ class SkillsTable: ) return skills - async def get_skills_by_user_id( - self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None - ) -> list[SkillUserModel]: - skills = await self.get_skills(db=db) - user_groups = await Groups.get_groups_by_member_id(user_id, db=db) - user_group_ids = {group.id for group in user_groups} - - result = [] - for skill in skills: - if skill.user_id == user_id: - result.append(skill) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='skill', - resource_id=skill.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(skill) - return result - async def search_skills( self, user_id: str, diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 319f0ec62d..9f24d274bb 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -97,7 +97,7 @@ class TagTable: async with get_async_db_context(db) as db: id = name.replace(' ', '_').lower() result = await db.execute(delete(Tag).filter_by(id=id, user_id=user_id)) - log.debug(f'res: {result.rowcount}') + log.debug('res: %s', result.rowcount) await db.commit() return True except Exception as e: diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index cbc21854ea..24e019aea9 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -169,20 +169,37 @@ class ToolsTable: for tool in tools } - async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]: + async def get_tools( + self, + defer_content: bool = False, + db: AsyncSession | None = None, + user_id: str | None = None, + user_group_ids: set[str] | None = None, + permission: str = 'read', + ) -> list[ToolUserModel]: async with get_async_db_context(db) as db: - if defer_content: - # Skip Tool.content (plugin source, potentially large) via a - # column select; Row attributes satisfy from_attributes. - result = await db.execute( - select( - Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at - ).order_by(Tool.updated_at.desc()) + # Skip Tool.content (plugin source, potentially large) via a + # column select; Row attributes satisfy from_attributes. + stmt = ( + select(Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at) + if defer_content + else select(Tool) + ).order_by(Tool.updated_at.desc()) + + if user_id is not None: + if user_group_ids is None: + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)} + stmt = AccessGrants.has_permission_filter( + db=db, + query=stmt, + DocumentModel=Tool, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='tool', + permission=permission, ) - all_tools = result.all() - else: - result = await db.execute(select(Tool).order_by(Tool.updated_at.desc())) - all_tools = result.scalars().all() + + result = await db.execute(stmt) + all_tools = result.all() if defer_content else result.scalars().all() user_ids = list(set(tool.user_id for tool in all_tools)) tool_ids = [tool.id for tool in all_tools] @@ -217,20 +234,15 @@ class ToolsTable: defer_content: bool = False, db: AsyncSession | None = None, ) -> list[ToolUserModel]: - tools = await self.get_tools(defer_content=defer_content, db=db) user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} - - # One grants query for all non-owned tools instead of one per tool - accessible_ids = await AccessGrants.get_accessible_resource_ids( - user_id=user_id, - resource_type='tool', - resource_ids=[tool.id for tool in tools if tool.user_id != user_id], - permission=permission, - user_group_ids=user_group_ids, + return await self.get_tools( + defer_content=defer_content, db=db, + user_id=user_id, + user_group_ids=user_group_ids, + permission=permission, ) - return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids] async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None: try: diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 1eff4932d5..3d132c529e 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -27,7 +27,6 @@ from sqlalchemy import ( select, update, ) -from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession #################### @@ -360,16 +359,15 @@ class UsersTable: sub: str, db: AsyncSession | None = None, ) -> UserModel | None: - """Look up a user by OAuth provider + subject claim (dialect-aware JSON filter).""" + """Look up a user by OAuth provider + subject claim.""" + sub = str(sub) async with get_async_db_context(db) as session: - dialect = session.bind.dialect.name - query = select(User) - if dialect == 'sqlite': - oauth_match = User.oauth.contains({provider: {'sub': sub}}) - query = query.where(oauth_match) - elif dialect == 'postgresql': - oauth_match = User.oauth[provider].cast(JSONB)['sub'].astext == sub - query = query.where(oauth_match) + # Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE. + sub_expr = User.oauth[provider]['sub'].as_string() + query = select(User).where(sub_expr == sub) + # SQLite preserves JSON numeric type here; Postgres ->> already compares numeric JSON as text. + if session.get_bind().dialect.name == 'sqlite' and sub.isdecimal(): + query = select(User).where(or_(sub_expr == sub, sub_expr == int(sub))) row = (await session.execute(query)).scalars().first() return UserModel.model_validate(row) if row else None @@ -379,27 +377,76 @@ class UsersTable: external_id: str, db: AsyncSession | None = None, ) -> UserModel | None: - """Look up a user by SCIM provider + external ID (dialect-aware JSON filter).""" + """Look up a user by SCIM provider + external ID.""" async with get_async_db_context(db) as session: - dialect = session.bind.dialect.name - query = select(User) - if dialect == 'sqlite': - scim_match = User.scim.contains({provider: {'external_id': external_id}}) - query = query.where(scim_match) - elif dialect == 'postgresql': - scim_match = User.scim[provider].cast(JSONB)['external_id'].astext == external_id - query = query.where(scim_match) + # Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE. + query = select(User).where(User.scim[provider]['external_id'].as_string() == external_id) row = (await session.execute(query)).scalars().first() return UserModel.model_validate(row) if row else None - async def get_users( + async def get_scim_users( self, filter: dict | None = None, + sort: dict | None = None, skip: int | None = None, limit: int | None = None, db: AsyncSession | None = None, ) -> dict: - """Paginated user listing with optional filters for role, group, and channel.""" + async with get_async_db_context(db) as session: + stmt = select(User).where(or_(User.oauth.cast(String) != 'null', User.scim.cast(String) != 'null')) + + if filter: + user_id = filter.get('id') + if user_id: + stmt = stmt.where(User.id == user_id) + + email = filter.get('email') + if email: + stmt = stmt.where(func.lower(User.email) == email.lower()) + + order_by = sort.get('order_by') if sort else None + direction = sort.get('direction') if sort else None + + if order_by == 'created_at': + stmt = stmt.order_by(User.created_at.asc() if direction == 'asc' else User.created_at.desc()) + + count_result = await session.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() + + if skip is not None: + stmt = stmt.offset(skip) + if limit is not None: + stmt = stmt.limit(limit) + + result = await session.execute(stmt) + users = result.scalars().all() + return { + 'users': [UserModel.model_validate(user) for user in users], + 'total': total, + } + + async def get_scim_user_by_id( + self, + id: str, + db: AsyncSession | None = None, + ) -> UserModel | None: + async with get_async_db_context(db) as session: + stmt = select(User).where( + User.id == id, + or_(User.oauth.cast(String) != 'null', User.scim.cast(String) != 'null'), + ) + user = (await session.execute(stmt)).scalars().first() + return UserModel.model_validate(user) if user else None + + async def get_users( + self, + filter: dict | None = None, + sort: dict | None = None, + skip: int | None = None, + limit: int | None = None, + db: AsyncSession | None = None, + ) -> dict: + """Paginated user listing with optional filters and sort.""" async with get_async_db_context(db) as session: # Deferred imports to avoid circular dependencies from open_webui.models.channels import ChannelMember @@ -460,64 +507,63 @@ class UsersTable: if exclude_roles: stmt = stmt.filter(~User.role.in_(exclude_roles)) - order_by = filter.get('order_by') - direction = filter.get('direction') + order_by = sort.get('order_by') if sort else None + direction = sort.get('direction') if sort else None - if order_by and order_by.startswith('group_id:'): - group_id = order_by.split(':', 1)[1] + if order_by and order_by.startswith('group_id:'): + group_id = order_by.split(':', 1)[1] - # Subquery that checks if the user belongs to the group - membership_exists = exists( - select(GroupMember.id).where( - GroupMember.user_id == User.id, - GroupMember.group_id == group_id, - ) + # Subquery that checks if the user belongs to the group + membership_exists = exists( + select(GroupMember.id).where( + GroupMember.user_id == User.id, + GroupMember.group_id == group_id, ) + ) - # CASE: user in group → 1, user not in group → 0 - group_sort = case((membership_exists, 1), else_=0) + # CASE: user in group → 1, user not in group → 0 + group_sort = case((membership_exists, 1), else_=0) - if direction == 'asc': - stmt = stmt.order_by(group_sort.asc(), User.name.asc()) - else: - stmt = stmt.order_by(group_sort.desc(), User.name.asc()) + if direction == 'asc': + stmt = stmt.order_by(group_sort.asc(), User.name.asc()) + else: + stmt = stmt.order_by(group_sort.desc(), User.name.asc()) - elif order_by == 'name': - if direction == 'asc': - stmt = stmt.order_by(User.name.asc()) - else: - stmt = stmt.order_by(User.name.desc()) + elif order_by == 'name': + if direction == 'asc': + stmt = stmt.order_by(User.name.asc()) + else: + stmt = stmt.order_by(User.name.desc()) - elif order_by == 'email': - if direction == 'asc': - stmt = stmt.order_by(User.email.asc()) - else: - stmt = stmt.order_by(User.email.desc()) + elif order_by == 'email': + if direction == 'asc': + stmt = stmt.order_by(User.email.asc()) + else: + stmt = stmt.order_by(User.email.desc()) - elif order_by == 'created_at': - if direction == 'asc': - stmt = stmt.order_by(User.created_at.asc()) - else: - stmt = stmt.order_by(User.created_at.desc()) + elif order_by == 'created_at': + if direction == 'asc': + stmt = stmt.order_by(User.created_at.asc()) + else: + stmt = stmt.order_by(User.created_at.desc()) - elif order_by == 'last_active_at': - if direction == 'asc': - stmt = stmt.order_by(User.last_active_at.asc()) - else: - stmt = stmt.order_by(User.last_active_at.desc()) + elif order_by == 'last_active_at': + if direction == 'asc': + stmt = stmt.order_by(User.last_active_at.asc()) + else: + stmt = stmt.order_by(User.last_active_at.desc()) - elif order_by == 'updated_at': - if direction == 'asc': - stmt = stmt.order_by(User.updated_at.asc()) - else: - stmt = stmt.order_by(User.updated_at.desc()) - elif order_by == 'role': - if direction == 'asc': - stmt = stmt.order_by(User.role.asc()) - else: - stmt = stmt.order_by(User.role.desc()) - - else: + elif order_by == 'updated_at': + if direction == 'asc': + stmt = stmt.order_by(User.updated_at.asc()) + else: + stmt = stmt.order_by(User.updated_at.desc()) + elif order_by == 'role': + if direction == 'asc': + stmt = stmt.order_by(User.role.asc()) + else: + stmt = stmt.order_by(User.role.desc()) + elif not filter: stmt = stmt.order_by(User.created_at.desc()) # Count BEFORE pagination @@ -636,7 +682,10 @@ class UsersTable: if not user: return None oauth = dict(user.oauth or {}) - oauth[provider] = {'sub': sub} + provider_oauth = oauth.get(provider) + provider_oauth = dict(provider_oauth) if isinstance(provider_oauth, dict) else {} + provider_oauth['sub'] = str(sub) + oauth[provider] = provider_oauth user.oauth = oauth await session.commit() return UserModel.model_validate(user) @@ -645,7 +694,7 @@ class UsersTable: self, id: str, provider: str, - external_id: str, + external_id: str | None, db: AsyncSession | None = None, ) -> UserModel | None: """Update or insert a SCIM provider/external_id pair into the user's scim JSON field.""" diff --git a/backend/open_webui/retrieval/loaders/datalab_marker.py b/backend/open_webui/retrieval/loaders/datalab_marker.py index be8cb9baaa..8f208c45ce 100644 --- a/backend/open_webui/retrieval/loaders/datalab_marker.py +++ b/backend/open_webui/retrieval/loaders/datalab_marker.py @@ -7,6 +7,7 @@ from typing import List, Optional import requests from fastapi import HTTPException, status from langchain_core.documents import Document +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -64,25 +65,6 @@ class DatalabMarkerLoader: } return mime_map.get(ext, 'application/octet-stream') - def check_marker_request_status(self, request_id: str) -> dict: - url = f'{self.api_base_url}/{request_id}' - headers = {'X-Api-Key': self.api_key} - try: - response = requests.get(url, headers=headers) - response.raise_for_status() - result = response.json() - log.info(f'Marker API status check for request {request_id}: {result}') - return result - except requests.HTTPError as e: - log.error(f'Error checking Marker request status: {e}') - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, - detail=f'Failed to check Marker request: {e}', - ) - except ValueError as e: - log.error(f'Invalid JSON checking Marker request: {e}') - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=f'Invalid JSON: {e}') - def load(self) -> List[Document]: filename = os.path.basename(self.file_path) mime_type = self._get_mime_type(filename) @@ -103,7 +85,10 @@ class DatalabMarkerLoader: form_data['additional_config'] = self.additional_config log.info( - f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}" + "Datalab Marker POST request parameters: {'filename': '%s', 'mime_type': '%s', **%s}", + filename, + mime_type, + form_data, ) try: @@ -167,7 +152,7 @@ class DatalabMarkerLoader: 'total_cost', ) } - log.info(f'Marker processing completed successfully: {json.dumps(summary, indent=2)}') + log.info('Marker processing completed successfully: %s', json.dumps(summary, indent=2)) break if status_val == 'failed' or success_val is False: @@ -234,7 +219,7 @@ class DatalabMarkerLoader: try: with open(output_path, 'w', encoding='utf-8') as f: f.write(full_text) - log.info(f'Saved Marker output to: {output_path}') + log.info('Saved Marker output to: %s', output_path) except Exception as e: log.warning(f'Failed to write marker output to disk: {e}') @@ -249,11 +234,11 @@ class DatalabMarkerLoader: images = final_result.get('images', {}) if images: metadata['image_count'] = len(images) - metadata['images'] = json.dumps(list(images.keys())) + metadata['images'] = JSONCodec.dumps(list(images.keys())) for k, v in metadata.items(): if isinstance(v, (dict, list)): - metadata[k] = json.dumps(v) + metadata[k] = JSONCodec.dumps(v) elif v is None: metadata[k] = '' diff --git a/backend/open_webui/retrieval/loaders/external_web.py b/backend/open_webui/retrieval/loaders/external_web.py index e3fd0b2614..1e1a817885 100644 --- a/backend/open_webui/retrieval/loaders/external_web.py +++ b/backend/open_webui/retrieval/loaders/external_web.py @@ -30,6 +30,9 @@ class ExternalWebLoader(BaseLoader): response = requests.post( self.external_url, headers={ + # LICENSE covers this Open WebUI user-agent identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) External Web Loader', 'Authorization': f'Bearer {self.external_api_key}', }, diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 4972f3ddab..f84b2a2b1b 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -1,7 +1,9 @@ import asyncio -import json +import csv import logging +import os import sys +import zipfile import ftfy import requests @@ -13,7 +15,6 @@ from langchain_community.document_loaders import ( Docx2txtLoader, PyPDFLoader, TextLoader, - YoutubeLoader, ) from langchain_core.documents import Document from open_webui.env import ( @@ -28,6 +29,7 @@ from open_webui.retrieval.loaders.mineru import MinerULoader from open_webui.retrieval.loaders.mistral import MistralLoader from open_webui.retrieval.loaders.paddleocr_vl import PADDLEOCR_VL_SUPPORTED_EXTENSIONS, PaddleOCRVLLoader from open_webui.utils.headers import get_user_groups_for_custom_headers +from open_webui.utils.json_codec import JSONCodec logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -88,6 +90,15 @@ known_source_ext = [ 'toml', ] +known_archive_ext = {'docx', 'epub', 'odt', 'pptx', 'xlsx'} +known_archive_content_types = { + 'application/epub+zip', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +} + class ExcelLoader: """Fallback Excel loader using pandas when unstructured is not installed.""" @@ -111,6 +122,52 @@ class ExcelLoader: ] +def get_csv_summary(filename: str, file_path: str, encoding: str) -> str | None: + try: + with open(file_path, newline='', encoding=encoding) as f: + sample = f.read(4096) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample) + except csv.Error: + dialect = csv.excel + + total_rows = 0 + max_columns = 0 + headers = [] + for row in csv.reader(f, dialect): + total_rows += 1 + max_columns = max(max_columns, len(row)) + if total_rows == 1: + headers = [header.lstrip('\ufeff') for header in row] + except Exception: + return None + + if total_rows == 0: + return None + + return ( + f'Table: {total_rows} rows incl. header; ' + f'{max(total_rows - 1, 0)} data rows; ' + f'{max_columns} columns: {", ".join(headers)}.' + ) + + +class CSVLoaderWithSummary: + def __init__(self, file_path: str, filename: str, encoding: str): + self.file_path = file_path + self.filename = filename + self.encoding = encoding + + def load(self) -> list[Document]: + docs = CSVLoader(self.file_path, encoding=self.encoding).load() + if os.getenv('ENABLE_RAG_CSV_SUMMARY', 'False').lower() == 'true': + summary = get_csv_summary(self.filename, self.file_path, self.encoding) + if summary: + docs.insert(0, Document(page_content=summary, metadata={'source': self.file_path, 'row': -1})) + return docs + + class PptxLoader: """Fallback PowerPoint loader using python-pptx when unstructured is not installed.""" @@ -138,10 +195,11 @@ class PptxLoader: class TikaLoader: - def __init__(self, url, file_path, mime_type=None, extract_images=None): + def __init__(self, url, file_path, mime_type=None, extract_images=None, server_version='3'): self.url = url self.file_path = file_path self.mime_type = mime_type + self.server_version = str(server_version or '3') self.extract_images = extract_images @@ -157,16 +215,15 @@ class TikaLoader: if self.extract_images == True: headers['X-Tika-PDFextractInlineImages'] = 'true' - endpoint = self.url - if not endpoint.endswith('/'): - endpoint += '/' - endpoint += 'tika/text' + endpoint_path = 'tika/json/text' if self.server_version == '4' else 'tika/text' + content_key = 'tk:content' if self.server_version == '4' else 'X-TIKA:content' + endpoint = f'{self.url.rstrip("/")}/{endpoint_path}' r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY) if r.ok: raw_metadata = r.json() - text = raw_metadata.get('X-TIKA:content', '').strip() + text = raw_metadata.get(content_key, '').strip() if 'Content-Type' in raw_metadata: headers['Content-Type'] = raw_metadata['Content-Type'] @@ -429,6 +486,27 @@ class Loader: def _get_loader(self, filename: str, file_content_type: str, file_path: str): file_ext = filename.split('.')[-1].lower() + if file_ext in known_archive_ext or file_content_type in known_archive_content_types: + max_file_size = self.kwargs.get('FILE_MAX_SIZE') + try: + max_file_size_bytes = int(max_file_size) * 1024 * 1024 if max_file_size else 100 * 1024 * 1024 + except (TypeError, ValueError): + max_file_size_bytes = 100 * 1024 * 1024 + + if max_file_size_bytes > 0: + try: + with zipfile.ZipFile(file_path) as archive: + uncompressed_size = sum(entry.file_size for entry in archive.infolist()) + except (zipfile.BadZipFile, OSError): + pass + else: + max_bytes = min( + max(10 * 1024 * 1024, os.path.getsize(file_path) * 100), + max_file_size_bytes, + ) + if uncompressed_size > max_bytes: + raise ValueError('Document archive is too large after decompression') + if ( self.engine == 'external' and self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_URL') @@ -455,6 +533,7 @@ class Loader: loader = TikaLoader( url=self.kwargs.get('TIKA_SERVER_URL'), file_path=file_path, + server_version=self.kwargs.get('TIKA_SERVER_VERSION'), extract_images=self.kwargs.get('PDF_EXTRACT_IMAGES'), ) elif ( @@ -508,8 +587,8 @@ class Loader: params = self.kwargs.get('DOCLING_PARAMS', {}) if not isinstance(params, dict): try: - params = json.loads(params) - except json.JSONDecodeError: + params = JSONCodec.loads(params) + except JSONCodec.JSONDecodeError: log.error('Invalid DOCLING_PARAMS format, expected JSON object') params = {} @@ -594,7 +673,11 @@ class Loader: mode=self.kwargs.get('PDF_LOADER_MODE', 'page'), ) elif file_ext == 'csv': - loader = CSVLoader(file_path, encoding=self._detect_text_encoding(file_path)) + loader = CSVLoaderWithSummary( + file_path, + filename, + self._detect_text_encoding(file_path), + ) elif file_ext == 'rst': try: from langchain_community.document_loaders import UnstructuredRSTLoader diff --git a/backend/open_webui/retrieval/loaders/microsoft_web_iq.py b/backend/open_webui/retrieval/loaders/microsoft_web_iq.py index fdf10e6332..5057aaeb49 100644 --- a/backend/open_webui/retrieval/loaders/microsoft_web_iq.py +++ b/backend/open_webui/retrieval/loaders/microsoft_web_iq.py @@ -10,7 +10,6 @@ from langchain_core.documents import Document log = logging.getLogger(__name__) -DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL = 'https://api.microsoft.ai/v3' MICROSOFT_BROWSE_RETRY_STATUS_CODES = {202, 429, 500, 502, 503, 504} MICROSOFT_BROWSE_MAX_RETRIES = 2 @@ -27,7 +26,7 @@ class MicrosoftWebIQLoader(BaseLoader): continue_on_failure: bool = True, ) -> None: self.urls = urls if isinstance(urls, list) else [urls] - self.api_base_url = (api_base_url or DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL).rstrip('/') + self.api_base_url = api_base_url.rstrip('/') self.api_key = api_key self.language = language self.verify_ssl = verify_ssl diff --git a/backend/open_webui/retrieval/loaders/mineru.py b/backend/open_webui/retrieval/loaders/mineru.py index c641ac9a72..4459515dc2 100644 --- a/backend/open_webui/retrieval/loaders/mineru.py +++ b/backend/open_webui/retrieval/loaders/mineru.py @@ -74,7 +74,7 @@ class MinerULoader: Load document using Local API (synchronous). Posts file to /file_parse endpoint and gets immediate response. """ - log.info(f'Using MinerU Local API at {self.api_url}') + log.info('Using MinerU Local API at %s', self.api_url) filename = os.path.basename(self.file_path) @@ -97,8 +97,8 @@ class MinerULoader: with open(self.file_path, 'rb') as f: files = {'files': (filename, f, 'application/octet-stream')} - log.info(f'Sending file to MinerU Local API: {filename}') - log.debug(f'Local API parameters: {form_data}') + log.info('Sending file to MinerU Local API: %s', filename) + log.debug('Local API parameters: %s', form_data) response = requests.post( f'{self.api_url}/file_parse', @@ -163,7 +163,7 @@ class MinerULoader: detail='MinerU returned empty markdown content', ) - log.info(f'Successfully parsed document with MinerU Local API: {filename}') + log.info('Successfully parsed document with MinerU Local API: %s', filename) # Create metadata metadata = { @@ -180,7 +180,7 @@ class MinerULoader: Load document using Cloud API (asynchronous). Uses batch upload endpoint to avoid need for public file URLs. """ - log.info(f'Using MinerU Cloud API at {self.api_url}') + log.info('Using MinerU Cloud API at %s', self.api_url) filename = os.path.basename(self.file_path) @@ -196,7 +196,7 @@ class MinerULoader: # Step 4: Download and extract markdown from ZIP markdown_content = self._download_and_extract_zip(result['full_zip_url'], filename) - log.info(f'Successfully parsed document with MinerU Cloud API: {filename}') + log.info('Successfully parsed document with MinerU Cloud API: %s', filename) # Create metadata metadata = { @@ -232,8 +232,8 @@ class MinerULoader: if self.page_ranges: request_body['files'][0]['page_ranges'] = self.page_ranges - log.info(f'Requesting upload URL for: {filename}') - log.debug(f'Cloud API request body: {request_body}') + log.info('Requesting upload URL for: %s', filename) + log.debug('Cloud API request body: %s', request_body) try: response = requests.post( @@ -284,7 +284,7 @@ class MinerULoader: ) upload_url = file_urls[0] - log.info(f'Received upload URL for batch: {batch_id}') + log.info('Received upload URL for batch: %s', batch_id) return batch_id, upload_url @@ -334,7 +334,7 @@ class MinerULoader: max_iterations = 300 # 10 minutes max (2 seconds per iteration) poll_interval = 2 # seconds - log.info(f'Polling batch status: {batch_id}') + log.info('Polling batch status: %s', batch_id) for iteration in range(max_iterations): try: @@ -393,7 +393,7 @@ class MinerULoader: state = file_result.get('state') if state == 'done': - log.info(f'Processing complete for {filename}') + log.info('Processing complete for %s', filename) return file_result elif state == 'failed': error_msg = file_result.get('err_msg', 'Unknown error') @@ -404,7 +404,7 @@ class MinerULoader: elif state in ['waiting-file', 'pending', 'running', 'converting']: # Still processing if iteration % 10 == 0: # Log every 20 seconds - log.info(f'Processing status: {state} (iteration {iteration + 1}/{max_iterations})') + log.info('Processing status: %s (iteration %s/%s)', state, iteration + 1, max_iterations) time.sleep(poll_interval) else: log.warning(f'Unknown state: {state}') @@ -421,7 +421,7 @@ class MinerULoader: Download ZIP file from CDN and extract markdown content. Returns the markdown content as a string. """ - log.info(f'Downloading results from: {zip_url}') + log.info('Downloading results from: %s', zip_url) try: response = requests.get(zip_url, timeout=60) @@ -452,7 +452,7 @@ class MinerULoader: read_errors = [] for member in md_members: - log.info(f'Found markdown file in ZIP: {member.filename}') + log.info('Found markdown file in ZIP: %s', member.filename) try: with zip_ref.open(member, 'r') as f: if self.max_markdown_bytes is None: @@ -515,5 +515,5 @@ class MinerULoader: detail='Extracted markdown content is empty', ) - log.info(f'Successfully extracted markdown content ({len(markdown_content)} characters)') + log.info('Successfully extracted markdown content (%s characters)', len(markdown_content)) return markdown_content diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py index d5886bd981..ad266deb92 100644 --- a/backend/open_webui/retrieval/loaders/mistral.py +++ b/backend/open_webui/retrieval/loaders/mistral.py @@ -1,16 +1,13 @@ -import asyncio import base64 import logging import os import sys import time -from contextlib import asynccontextmanager from typing import Any, Dict, List, Optional -import aiohttp import requests from langchain_core.documents import Document -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL +from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL from open_webui.utils.headers import include_user_info_headers logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -19,15 +16,12 @@ log = logging.getLogger(__name__) class MistralLoader: """ - Enhanced Mistral OCR loader with both sync and async support. + Enhanced Mistral OCR loader. Loads documents by processing them through the Mistral OCR API. Performance Optimizations: - Differentiated timeouts for different operations - Intelligent retry logic with exponential backoff - - Memory-efficient file streaming for large files - - Connection pooling and keepalive optimization - - Semaphore-based concurrency control for batch processing - Enhanced error handling with retryable error classification """ @@ -63,7 +57,6 @@ class MistralLoader: self.base_url = base_url.rstrip('/') if base_url else 'https://api.mistral.ai/v1' self.api_key = api_key self.file_path = file_path - self.timeout = timeout self.max_retries = max_retries self.debug = enable_debug_logging self.use_base64 = use_base64 @@ -118,32 +111,6 @@ class MistralLoader: log.error(f'JSON decode error: {json_err} - Response: {response.text}') raise # Re-raise after logging - async def _handle_response_async(self, response: aiohttp.ClientResponse) -> Dict[str, Any]: - """Async version of response handling with better error info.""" - try: - response.raise_for_status() - - # Check content type - content_type = response.headers.get('content-type', '') - if 'application/json' not in content_type: - if response.status == 204: - return {} - text = await response.text() - raise ValueError(f'Unexpected content type: {content_type}, body: {text[:200]}...') - - return await response.json() - - except aiohttp.ClientResponseError as e: - error_text = await response.text() if response else 'No response' - log.error(f'HTTP {e.status}: {e.message} - Response: {error_text[:500]}') - raise - except aiohttp.ClientError as e: - log.error(f'Client error: {e}') - raise - except Exception as e: - log.error(f'Unexpected error processing response: {e}') - raise - def _is_retryable_error(self, error: Exception) -> bool: """ ENHANCEMENT: Intelligent error classification for retry logic. @@ -173,10 +140,6 @@ class MistralLoader: status_code = error.response.status_code return status_code >= 500 or status_code == 429 return False - if isinstance(error, (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError)): - return True # Async network/timeout errors are retryable - if isinstance(error, aiohttp.ClientResponseError): - return error.status >= 500 or error.status == 429 return False # All other errors are non-retryable def _retry_request_sync(self, request_func, *args, **kwargs): @@ -203,32 +166,11 @@ class MistralLoader: ) time.sleep(wait_time) - async def _retry_request_async(self, request_func, *args, **kwargs): - """ - ENHANCEMENT: Async retry logic with intelligent error classification. - - Async version of retry logic that doesn't block the event loop during - wait periods. Uses the same exponential backoff strategy as sync version. - """ - for attempt in range(self.max_retries): - try: - return await request_func(*args, **kwargs) - except Exception as e: - if attempt == self.max_retries - 1 or not self._is_retryable_error(e): - raise - - # PERFORMANCE OPTIMIZATION: Non-blocking exponential backoff - wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds - log.warning( - f'Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s...' - ) - await asyncio.sleep(wait_time) # Non-blocking wait - def _upload_file(self) -> str: """ PERFORMANCE OPTIMIZATION: Enhanced file upload with streaming consideration. - Uploads the file to Mistral for OCR processing (sync version). + Uploads the file to Mistral for OCR processing. Uses context manager for file handling to ensure proper resource cleanup. Although streaming is not enabled for this endpoint, the file is opened in a context manager to minimize memory usage duration. @@ -261,56 +203,15 @@ class MistralLoader: file_id = response_data.get('id') if not file_id: raise ValueError('File ID not found in upload response.') - log.info(f'File uploaded successfully. File ID: {file_id}') + log.info('File uploaded successfully. File ID: %s', file_id) return file_id except Exception as e: log.error(f'Failed to upload file: {e}') raise - async def _upload_file_async(self, session: aiohttp.ClientSession) -> str: - """Async file upload with streaming for better memory efficiency.""" - url = f'{self.base_url}/files' - - async def upload_request(): - # Open inside the request so the handle stays valid for the whole - # streamed POST and is closed right after. - with open(self.file_path, 'rb') as f: - writer = aiohttp.MultipartWriter('form-data') - - # Add purpose field - purpose_part = writer.append('ocr') - purpose_part.set_content_disposition('form-data', name='purpose') - - # Stream the file. aiohttp builds a payload from the file object; - # the previous aiohttp.streams.FilePayload was removed upstream - # (payloads live in aiohttp.payload and there is no FilePayload), - # so this path raised AttributeError on every async OCR upload. - file_part = writer.append(f, {'Content-Type': 'application/pdf'}) - file_part.set_content_disposition('form-data', name='file', filename=self.file_name) - - self._debug_log(f'Uploading file: {self.file_name} ({self.file_size:,} bytes)') - - async with session.post( - url, - data=writer, - headers=self.headers, - timeout=aiohttp.ClientTimeout(total=self.upload_timeout), - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as response: - return await self._handle_response_async(response) - - response_data = await self._retry_request_async(upload_request) - - file_id = response_data.get('id') - if not file_id: - raise ValueError('File ID not found in upload response.') - - log.info(f'File uploaded successfully. File ID: {file_id}') - return file_id - def _get_signed_url(self, file_id: str) -> str: - """Retrieves a temporary signed URL for the uploaded file (sync version).""" - log.info(f'Getting signed URL for file ID: {file_id}') + """Retrieves a temporary signed URL for the uploaded file.""" + log.info('Getting signed URL for file ID: %s', file_id) url = f'{self.base_url}/files/{file_id}/url' params = {'expiry': 1} signed_url_headers = {**self.headers, 'Accept': 'application/json'} @@ -330,35 +231,8 @@ class MistralLoader: log.error(f'Failed to get signed URL: {e}') raise - async def _get_signed_url_async(self, session: aiohttp.ClientSession, file_id: str) -> str: - """Async signed URL retrieval.""" - url = f'{self.base_url}/files/{file_id}/url' - params = {'expiry': 1} - - headers = {**self.headers, 'Accept': 'application/json'} - - async def url_request(): - self._debug_log(f'Getting signed URL for file ID: {file_id}') - async with session.get( - url, - headers=headers, - params=params, - timeout=aiohttp.ClientTimeout(total=self.url_timeout), - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as response: - return await self._handle_response_async(response) - - response_data = await self._retry_request_async(url_request) - - signed_url = response_data.get('url') - if not signed_url: - raise ValueError('Signed URL not found in response.') - - self._debug_log('Signed URL received successfully') - return signed_url - def _process_ocr(self, signed_url: str) -> Dict[str, Any]: - """Sends the signed URL to the OCR endpoint for processing (sync version).""" + """Sends the signed URL to the OCR endpoint for processing.""" log.info('Processing OCR via Mistral API') url = f'{self.base_url}/ocr' ocr_headers = { @@ -388,113 +262,24 @@ class MistralLoader: log.error(f'Failed during OCR processing: {e}') raise - async def _process_ocr_async(self, session: aiohttp.ClientSession, signed_url: str) -> Dict[str, Any]: - """Async OCR processing with timing metrics.""" - url = f'{self.base_url}/ocr' - - headers = { - **self.headers, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } - - payload = { - 'model': 'mistral-ocr-latest', - 'document': { - 'type': 'document_url', - 'document_url': signed_url, - }, - 'include_image_base64': False, - } - - async def ocr_request(): - log.info('Starting OCR processing via Mistral API') - start_time = time.time() - - async with session.post( - url, - json=payload, - headers=headers, - timeout=aiohttp.ClientTimeout(total=self.ocr_timeout), - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as response: - ocr_response = await self._handle_response_async(response) - - processing_time = time.time() - start_time - log.info(f'OCR processing completed in {processing_time:.2f}s') - - return ocr_response - - return await self._retry_request_async(ocr_request) - def _get_file_data_url(self) -> str: with open(self.file_path, 'rb') as f: encoded_file = base64.b64encode(f.read()).decode('utf-8') return f'data:application/pdf;base64,{encoded_file}' def _delete_file(self, file_id: str) -> None: - """Deletes the file from Mistral storage (sync version).""" - log.info(f'Deleting uploaded file ID: {file_id}') + """Deletes the file from Mistral storage.""" + log.info('Deleting uploaded file ID: %s', file_id) url = f'{self.base_url}/files/{file_id}' try: response = requests.delete(url, headers=self.headers, timeout=self.cleanup_timeout) delete_response = self._handle_response(response) - log.info(f'File deleted successfully: {delete_response}') + log.info('File deleted successfully: %s', delete_response) except Exception as e: # Log error but don't necessarily halt execution if deletion fails log.error(f'Failed to delete file ID {file_id}: {e}') - async def _delete_file_async(self, session: aiohttp.ClientSession, file_id: str) -> None: - """Async file deletion with error tolerance.""" - try: - - async def delete_request(): - self._debug_log(f'Deleting file ID: {file_id}') - async with session.delete( - url=f'{self.base_url}/files/{file_id}', - headers=self.headers, - timeout=aiohttp.ClientTimeout(total=self.cleanup_timeout), - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as response: - return await self._handle_response_async(response) - - await self._retry_request_async(delete_request) - self._debug_log(f'File {file_id} deleted successfully') - - except Exception as e: - # Don't fail the entire process if cleanup fails - log.warning(f'Failed to delete file ID {file_id}: {e}') - - @asynccontextmanager - async def _get_session(self): - """Context manager for HTTP session with optimized settings.""" - connector = aiohttp.TCPConnector( - limit=20, # Increased total connection limit for better throughput - limit_per_host=10, # Increased per-host limit for API endpoints - ttl_dns_cache=600, # Longer DNS cache TTL (10 minutes) - use_dns_cache=True, - keepalive_timeout=60, # Increased keepalive for connection reuse - enable_cleanup_closed=True, - force_close=False, # Allow connection reuse - resolver=aiohttp.AsyncResolver(), # Use async DNS resolver - ) - - timeout = aiohttp.ClientTimeout( - total=self.timeout, - connect=30, # Connection timeout - sock_read=60, # Socket read timeout - ) - - async with aiohttp.ClientSession( - connector=connector, - timeout=timeout, - headers={'User-Agent': 'OpenWebUI-MistralLoader/2.0'}, - raise_for_status=False, # We handle status codes manually - trust_env=True, - ) as session: - yield session - def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]: """Process OCR results into Document objects with enhanced metadata and memory efficiency.""" pages_data = ocr_response.get('pages') @@ -519,7 +304,7 @@ class MistralLoader: if page_content is None or page_index is None: skipped_pages += 1 self._debug_log( - f"Skipping page due to missing 'markdown' or 'index'. Data keys: {list(page_data.keys())}" + "Skipping page due to missing 'markdown' or 'index'. Data keys: %s", list(page_data.keys()) ) continue @@ -531,7 +316,7 @@ class MistralLoader: if not cleaned_content: skipped_pages += 1 - self._debug_log(f'Skipping empty page {page_index}') + self._debug_log('Skipping empty page %s', page_index) continue # Create document with optimized metadata @@ -551,7 +336,7 @@ class MistralLoader: ) if skipped_pages > 0: - log.info(f'Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages') + log.info('Processed %s pages, skipped %s empty/invalid pages', len(documents), skipped_pages) if not documents: # Case where pages existed but none had valid markdown/index @@ -572,7 +357,6 @@ class MistralLoader: def load(self) -> List[Document]: """ Executes the full OCR workflow: upload, get URL, process OCR, delete file. - Synchronous version for backward compatibility. Returns: A list of Document objects, one for each page processed. @@ -584,7 +368,7 @@ class MistralLoader: if self.use_base64: documents = self._process_results(self._process_ocr(self._get_file_data_url())) total_time = time.time() - start_time - log.info(f'Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents') + log.info('Sync OCR workflow completed in %.2fs, produced %s documents', total_time, len(documents)) return documents # 1. Upload file @@ -600,7 +384,7 @@ class MistralLoader: documents = self._process_results(ocr_response) total_time = time.time() - start_time - log.info(f'Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents') + log.info('Sync OCR workflow completed in %.2fs, produced %s documents', total_time, len(documents)) return documents @@ -625,125 +409,3 @@ class MistralLoader: except Exception as del_e: # Log deletion error, but don't overwrite original error if one occurred log.error(f'Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}') - - async def load_async(self) -> List[Document]: - """ - Asynchronous OCR workflow execution with optimized performance. - - Returns: - A list of Document objects, one for each page processed. - """ - file_id = None - start_time = time.time() - - try: - async with self._get_session() as session: - if self.use_base64: - ocr_response = await self._process_ocr_async(session, self._get_file_data_url()) - documents = self._process_results(ocr_response) - total_time = time.time() - start_time - log.info(f'Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents') - return documents - - # 1. Upload file with streaming - file_id = await self._upload_file_async(session) - - # 2. Get signed URL - signed_url = await self._get_signed_url_async(session, file_id) - - # 3. Process OCR - ocr_response = await self._process_ocr_async(session, signed_url) - - # 4. Process results - documents = self._process_results(ocr_response) - - total_time = time.time() - start_time - log.info(f'Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents') - - return documents - - except Exception as e: - total_time = time.time() - start_time - log.error(f'Async OCR workflow failed after {total_time:.2f}s: {e}') - return [ - Document( - page_content=f'Error during OCR processing: {e}', - metadata={ - 'error': 'processing_failed', - 'file_name': self.file_name, - }, - ) - ] - finally: - # 5. Cleanup - always attempt file deletion - if file_id: - try: - async with self._get_session() as session: - await self._delete_file_async(session, file_id) - except Exception as cleanup_error: - log.error(f'Cleanup failed for file ID {file_id}: {cleanup_error}') - - @staticmethod - async def load_multiple_async( - loaders: List['MistralLoader'], - max_concurrent: int = 5, # Limit concurrent requests - ) -> List[List[Document]]: - """ - Process multiple files concurrently with controlled concurrency. - - Args: - loaders: List of MistralLoader instances - max_concurrent: Maximum number of concurrent requests - - Returns: - List of document lists, one for each loader - """ - if not loaders: - return [] - - log.info(f'Starting concurrent processing of {len(loaders)} files with max {max_concurrent} concurrent') - start_time = time.time() - - # Use semaphore to control concurrency - semaphore = asyncio.Semaphore(max_concurrent) - - async def process_with_semaphore(loader: 'MistralLoader') -> List[Document]: - async with semaphore: - return await loader.load_async() - - # Process all files with controlled concurrency - tasks = [process_with_semaphore(loader) for loader in loaders] - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Handle any exceptions in results - processed_results = [] - for i, result in enumerate(results): - if isinstance(result, Exception): - log.error(f'File {i} failed: {result}') - processed_results.append( - [ - Document( - page_content=f'Error processing file: {result}', - metadata={ - 'error': 'batch_processing_failed', - 'file_index': i, - }, - ) - ] - ) - else: - processed_results.append(result) - - # MONITORING: Log comprehensive batch processing statistics - total_time = time.time() - start_time - total_docs = sum(len(docs) for docs in processed_results) - success_count = sum(1 for result in results if not isinstance(result, Exception)) - failure_count = len(results) - success_count - - log.info( - f'Batch processing completed in {total_time:.2f}s: ' - f'{success_count} files succeeded, {failure_count} files failed, ' - f'produced {total_docs} total documents' - ) - - return processed_results diff --git a/backend/open_webui/retrieval/loaders/paddleocr_vl.py b/backend/open_webui/retrieval/loaders/paddleocr_vl.py index 50172ddb47..5f1107dd9e 100644 --- a/backend/open_webui/retrieval/loaders/paddleocr_vl.py +++ b/backend/open_webui/retrieval/loaders/paddleocr_vl.py @@ -35,7 +35,7 @@ class PaddleOCRVLLoader: self.file_name = os.path.basename(file_path) def load(self) -> List[Document]: - log.info(f'Processing with PaddleOCR-vl: {self.file_path}') + log.info('Processing with PaddleOCR-vl: %s', self.file_path) try: with open(self.file_path, 'rb') as file: @@ -96,7 +96,7 @@ class PaddleOCRVLLoader: ) if skipped_pages > 0: - log.info(f'PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.') + log.info('PaddleOCR-vl: Processed %s pages, skipped %s empty pages.', len(documents), skipped_pages) if not documents: log.warning('No valid text content found by PaddleOCR-vl.') diff --git a/backend/open_webui/retrieval/loaders/tavily.py b/backend/open_webui/retrieval/loaders/tavily.py index bdf70830e4..39c39f91fb 100644 --- a/backend/open_webui/retrieval/loaders/tavily.py +++ b/backend/open_webui/retrieval/loaders/tavily.py @@ -4,6 +4,7 @@ from typing import Iterator, List, Literal, Union import requests from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document +from open_webui.env import TAVILY_API_BASE_URL log = logging.getLogger(__name__) @@ -48,7 +49,7 @@ class TavilyLoader(BaseLoader): self.urls = urls if isinstance(urls, list) else [urls] self.extract_depth = extract_depth self.continue_on_failure = continue_on_failure - self.api_url = 'https://api.tavily.com/extract' + self.api_url = f'{TAVILY_API_BASE_URL}/extract' def lazy_load(self) -> Iterator[Document]: """Extract and yield documents from the URLs using Tavily Extract API.""" diff --git a/backend/open_webui/retrieval/loaders/youtube.py b/backend/open_webui/retrieval/loaders/youtube.py index ad4c3524ac..a1ee8d1128 100644 --- a/backend/open_webui/retrieval/loaders/youtube.py +++ b/backend/open_webui/retrieval/loaders/youtube.py @@ -18,6 +18,32 @@ ALLOWED_NETLOCS = { } +class YoutubeTranscriptError(Exception): + """A YouTube transcript could not be retrieved.""" + + +def _transcript_error_message(error: Exception, video_id: str) -> str: + name = type(error).__name__ + + if name in {'RequestBlocked', 'IpBlocked'}: + return ( + f'YouTube blocked the transcript request for {video_id} from this server. ' + 'This usually means the server address is rate limited or belongs to a cloud ' + 'provider. A proxy for these requests can be configured under Admin Settings, ' + 'Web Search, Youtube Proxy URL.' + ) + if name == 'TranscriptsDisabled': + return f'Transcripts are disabled for the YouTube video {video_id}.' + if name == 'AgeRestricted': + return f'The YouTube video {video_id} is age restricted, so its transcript cannot be retrieved.' + if name in {'VideoUnavailable', 'VideoUnplayable', 'InvalidVideoId'}: + return f'The YouTube video {video_id} is unavailable.' + if name == 'PoTokenRequired': + return f'YouTube requires additional verification to return the transcript for {video_id}.' + + return f'Could not retrieve a transcript for the YouTube video {video_id}.' + + def _parse_video_id(url: str) -> Optional[str]: """Parse a YouTube URL and return the video ID if valid, otherwise None.""" parsed_url = urlparse(url) @@ -90,7 +116,7 @@ class YoutubeLoader: if self.proxy_url: youtube_proxies = GenericProxyConfig(http_url=self.proxy_url, https_url=self.proxy_url) - log.debug(f'Using proxy URL: {self.proxy_url[:14]}...') + log.debug('Using proxy URL: %s...', self.proxy_url[:14]) else: youtube_proxies = None @@ -98,31 +124,31 @@ class YoutubeLoader: try: transcript_list = transcript_api.list(self.video_id) except Exception as e: - log.warning(f'Loading YouTube transcript failed: {e}') - return [] + log.warning('Loading YouTube transcript failed: %s', e) + raise YoutubeTranscriptError(_transcript_error_message(e, self.video_id)) from e # Try each language in order of priority for lang in self.language: try: transcript = transcript_list.find_transcript([lang]) if transcript.is_generated: - log.debug(f"Found generated transcript for language '{lang}'") + log.debug("Found generated transcript for language '%s'", lang) try: transcript = transcript_list.find_manually_created_transcript([lang]) - log.debug(f"Found manual transcript for language '{lang}'") + log.debug("Found manual transcript for language '%s'", lang) except NoTranscriptFound: - log.debug(f"No manual transcript found for language '{lang}', using generated") + log.debug("No manual transcript found for language '%s', using generated", lang) pass - log.debug(f"Found transcript for language '{lang}'") + log.debug("Found transcript for language '%s'", lang) try: transcript_pieces: List[Dict[str, Any]] = transcript.fetch() except ParseError: - log.debug(f"Empty or invalid transcript for language '{lang}'") + log.debug("Empty or invalid transcript for language '%s'", lang) continue if not transcript_pieces: - log.debug(f"Empty transcript for language '{lang}'") + log.debug("Empty transcript for language '%s'", lang) continue transcript_text = ' '.join( @@ -135,18 +161,20 @@ class YoutubeLoader: ) return [Document(page_content=transcript_text, metadata=self._metadata)] except NoTranscriptFound: - log.debug(f"No transcript found for language '{lang}'") + log.debug("No transcript found for language '%s'", lang) continue except Exception as e: - log.info(f"Error finding transcript for language '{lang}'") - raise e + log.info("Error finding transcript for language '%s'", lang) + raise YoutubeTranscriptError(_transcript_error_message(e, self.video_id)) from e # If we get here, all languages failed languages_tried = ', '.join(self.language) log.warning( f'No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed.' ) - raise NoTranscriptFound(self.video_id, self.language, list(transcript_list)) + raise YoutubeTranscriptError( + f'No transcript found for the YouTube video {self.video_id} in these languages: {languages_tried}.' + ) async def aload(self) -> Generator[Document, None, None]: """Asynchronously load YouTube transcripts into `Document` objects.""" diff --git a/backend/open_webui/retrieval/models/colbert.py b/backend/open_webui/retrieval/models/colbert.py index d11bde1b09..94e6f8af5e 100644 --- a/backend/open_webui/retrieval/models/colbert.py +++ b/backend/open_webui/retrieval/models/colbert.py @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) class ColBERT(BaseReranker): def __init__(self, name, **kwargs) -> None: - log.info('ColBERT: Loading model', name) + log.info('ColBERT: Loading model %s', name) self.device = 'cuda' if torch.cuda.is_available() else 'cpu' DOCKER = kwargs.get('env') == 'docker' diff --git a/backend/open_webui/retrieval/models/external.py b/backend/open_webui/retrieval/models/external.py index 6d2849eb88..a66a2c50bf 100644 --- a/backend/open_webui/retrieval/models/external.py +++ b/backend/open_webui/retrieval/models/external.py @@ -35,8 +35,8 @@ class ExternalReranker(BaseReranker): } try: - log.info(f'ExternalReranker:predict:model {self.model}') - log.info(f'ExternalReranker:predict:query {query}') + log.info('ExternalReranker:predict:model %s', self.model) + log.info('ExternalReranker:predict:query %s', query) headers = { 'Content-Type': 'application/json', diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 952b1e6b26..294309984e 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -6,7 +6,6 @@ import logging import os import re import time -from concurrent.futures import ThreadPoolExecutor from typing import Awaitable, Optional, Union from urllib.parse import quote @@ -25,6 +24,7 @@ from open_webui.config import ( RAG_EMBEDDING_QUERY_PREFIX, VECTOR_DB, ) +from open_webui.constants import ERROR_MESSAGES from open_webui.env import ( AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, @@ -50,7 +50,7 @@ from open_webui.retrieval.vector.main import GetResult, SearchResult from open_webui.retrieval.web.utils import get_web_loader from open_webui.utils.access_control.files import get_owner_accessible_folder_files, has_access_to_file from open_webui.utils.access_control.folders import has_folder_access -from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.headers import get_json_bearer_headers, include_user_info_headers from open_webui.utils.misc import get_content_from_message, get_message_list log = logging.getLogger(__name__) @@ -68,6 +68,7 @@ def is_youtube_url(url: str) -> bool: LOADER_CONFIG_KEYS = { + 'file_max_size': 'rag.file.max_size', 'youtube_language': 'rag.youtube_loader_language', 'youtube_proxy_url': 'rag.youtube_loader_proxy_url', 'web_loader_ssl_verification': 'web.loader.ssl_verification', @@ -103,6 +104,7 @@ LOADER_CONFIG_KEYS = { 'EXTERNAL_DOCUMENT_LOADER_API_KEY': 'rag.external_document_loader_api_key', 'EXTERNAL_DOCUMENT_LOADER_HEADERS': 'rag.external_document_loader_headers', 'TIKA_SERVER_URL': 'rag.tika_server_url', + 'TIKA_SERVER_VERSION': 'rag.tika_server_version', 'DOCLING_SERVER_URL': 'rag.docling_server_url', 'DOCLING_API_KEY': 'rag.docling_api_key', 'DOCLING_PARAMS': 'rag.docling_params', @@ -151,6 +153,7 @@ def build_loader_from_config(request, config: dict): from open_webui.retrieval.loaders.main import Loader loader_config = {key: config.get(key) for key in LOADER_CONFIG_KEYS if key.isupper()} + loader_config['FILE_MAX_SIZE'] = config.get('file_max_size') return Loader( engine=loader_config['CONTENT_EXTRACTION_ENGINE'], **{key: value for key, value in loader_config.items() if key != 'CONTENT_EXTRACTION_ENGINE'}, @@ -183,11 +186,20 @@ def _extract_text_from_binary_response( suffix = '.' + filename.split('.')[-1].lower() if '.' in filename else '' - with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: - tmp.write(response.content) - tmp_path = tmp.name + max_size = loader_config.get('file_max_size') + max_bytes = int(max_size) * 1024 * 1024 if max_size else 0 + tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix) try: + downloaded = 0 + # Stream to disk; response.content buffers the whole body in memory first. + with os.fdopen(tmp_fd, 'wb') as tmp: + for chunk in response.iter_content(64 * 1024): + downloaded += len(chunk) + if max_bytes and downloaded > max_bytes: + raise ValueError(ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size} MB')) + tmp.write(chunk) + loader = build_loader_from_config(request, loader_config) docs = loader.load(filename, content_type, tmp_path) for doc in docs: @@ -198,14 +210,24 @@ def _extract_text_from_binary_response( os.remove(tmp_path) +TEXT_APPLICATION_CONTENT_TYPES = { + 'application/javascript', + 'application/json', + 'application/xml', + 'application/x-javascript', +} + + def _is_text_content_type(content_type: str) -> bool: """Return True if the content type should be handled by the web loader.""" ct = content_type.split(';')[0].strip().lower() + if not ct: + return True if ct.startswith('text/'): return True - if any(t in ct for t in ['xml', 'json', 'javascript']): + if ct in TEXT_APPLICATION_CONTENT_TYPES: return True - return not ct # empty / missing → assume HTML + return ct.endswith(('+xml', '+json')) async def get_content_from_url(request, url: str) -> str: @@ -218,7 +240,7 @@ async def get_content_from_url(request, url: str) -> str: def _get_content_from_url_sync(request, url: str, loader_config): - from open_webui.retrieval.web.utils import validate_url, _SSRFSafeAdapter + from open_webui.retrieval.web.utils import validate_url, get_ssrf_safe_requests_session # Validate URL before making any request (blocks private IPs, non-HTTP, filter list) validate_url(url) @@ -242,9 +264,7 @@ def _get_content_from_url_sync(request, url: str, loader_config): # cloud-metadata 169.254.169.254) via a public host that redirects internally. try: # Probe through the connect-time SSRF guard; bare requests.get re-resolves (DNS-rebinding gap). - session = requests.Session() - session.mount('http://', _SSRFSafeAdapter()) - session.mount('https://', _SSRFSafeAdapter()) + session = get_ssrf_safe_requests_session() response = session.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS) response.raise_for_status() content_type = response.headers.get('Content-Type', '') @@ -311,7 +331,7 @@ class VectorSearchRetriever(BaseRetriever): def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None): try: - log.debug(f'query_doc:doc {collection_name}') + log.debug('query_doc:doc %s', collection_name) result = VECTOR_DB_CLIENT.search( collection_name=collection_name, vectors=[query_embedding], @@ -319,7 +339,7 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user: ) if result: - log.info(f'query_doc:result {result.ids} {result.metadatas}') + log.info('query_doc:result %s %s', result.ids, result.metadatas) return result except Exception as e: @@ -329,11 +349,11 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user: def get_doc(collection_name: str, user: UserModel = None): try: - log.debug(f'get_doc:doc {collection_name}') + log.debug('get_doc:doc %s', collection_name) result = VECTOR_DB_CLIENT.get(collection_name=collection_name) if result: - log.info(f'query_doc:result {result.ids} {result.metadatas}') + log.info('query_doc:result %s %s', result.ids, result.metadatas) return result except Exception as e: @@ -458,7 +478,7 @@ async def query_doc_with_native_hybrid_search( 'metadatas': [metadatas], } except Exception as e: - log.debug(f'Native hybrid search failed for {collection_name}, falling back to legacy hybrid search: {e}') + log.debug('Native hybrid search failed for %s, falling back to legacy hybrid search: %s', collection_name, e) return None @@ -511,7 +531,7 @@ async def query_doc_with_hybrid_search( log.warning(f'query_doc_with_hybrid_search:no_docs {collection_name}') return {'documents': [], 'metadatas': [], 'distances': []} - log.debug(f'query_doc_with_hybrid_search:doc {collection_name}') + log.debug('query_doc_with_hybrid_search:doc %s', collection_name) original_texts = collection_result.documents[0] bm25_metadatas = [ @@ -586,7 +606,7 @@ async def query_doc_with_hybrid_search( 'metadatas': [metadatas], } - log.info('query_doc_with_hybrid_search:result ' + f'{result["metadatas"]} {result["distances"]}') + log.info('query_doc_with_hybrid_search:result %s %s', result['metadatas'], result['distances']) return result except Exception as e: log.exception(f'Error querying doc {collection_name} with hybrid search: {e}') @@ -634,7 +654,7 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict: if isinstance(document, str): doc_hash = (metadata or {}).get(CHUNK_HASH_KEY) or _content_hash(document) - if doc_hash not in combined.keys(): + if doc_hash not in combined: combined[doc_hash] = (distance, document, metadata) continue # if doc is new, no further comparison is needed @@ -708,7 +728,7 @@ async def query_collection( enable_enriched_texts=config.get('rag.enable_hybrid_search_enriched_texts'), ) except Exception as e: - log.debug(f'Hybrid search failed, falling back to vector search: {e}') + log.debug('Hybrid search failed, falling back to vector search: %s', e) results = [] error = False @@ -737,15 +757,15 @@ async def query_collection( # Generate all query embeddings (in one call) query_embeddings = await embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX) - log.debug(f'query_collection: processing {len(queries)} queries across {len(collection_names)} collections') + log.debug('query_collection: processing %s queries across %s collections', len(queries), len(collection_names)) - with ThreadPoolExecutor() as executor: - future_results = [] - for query_embedding in query_embeddings: - for collection_name in collection_names: - result = executor.submit(process_query_collection, collection_name, query_embedding) - future_results.append(result) - task_results = [future.result() for future in future_results] + task_results = await asyncio.gather( + *[ + asyncio.to_thread(process_query_collection, collection_name, query_embedding) + for query_embedding in query_embeddings + for collection_name in collection_names + ] + ) for result, err in task_results: if err is not None: @@ -813,7 +833,7 @@ async def query_collection_with_hybrid_search( collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names))) - log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...') + log.info('Starting hybrid search for %s queries in %s collections...', len(queries), len(collection_names)) async def process_query(collection_name, query): try: @@ -867,15 +887,12 @@ def generate_openai_batch_embeddings( prefix: str = None, user: UserModel = None, ) -> list[list[float]]: - log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}') + log.debug('generate_openai_batch_embeddings:model %s batch size: %s', model, len(texts)) json_data = {'input': texts, 'model': model} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {key}', - } + headers = get_json_bearer_headers(key) if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) @@ -900,15 +917,12 @@ async def agenerate_openai_batch_embeddings( prefix: str = None, user: UserModel = None, ) -> list[list[float]]: - log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}') + log.debug('agenerate_openai_batch_embeddings:model %s batch size: %s', model, len(texts)) form_data = {'input': texts, 'model': model} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {key}', - } + headers = get_json_bearer_headers(key) if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) @@ -938,7 +952,7 @@ def generate_azure_openai_batch_embeddings( prefix: str = None, user: UserModel = None, ) -> list[list[float]]: - log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}') + log.debug('generate_azure_openai_batch_embeddings:deployment %s batch size: %s', model, len(texts)) json_data = {'input': texts} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix @@ -980,7 +994,7 @@ async def agenerate_azure_openai_batch_embeddings( prefix: str = None, user: UserModel = None, ) -> list[list[float]]: - log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}') + log.debug('agenerate_azure_openai_batch_embeddings:deployment %s batch size: %s', model, len(texts)) form_data = {'input': texts} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix @@ -1019,15 +1033,12 @@ def generate_ollama_batch_embeddings( prefix: str = None, user: UserModel = None, ) -> list[list[float]]: - log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}') + log.debug('generate_ollama_batch_embeddings:model %s batch size: %s', model, len(texts)) json_data = {'input': texts, 'model': model, 'truncate': True} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {key}', - } + headers = get_json_bearer_headers(key) if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) @@ -1055,15 +1066,12 @@ async def agenerate_ollama_batch_embeddings( prefix: str = None, user: UserModel = None, ) -> list[list[float]]: - log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}') + log.debug('agenerate_ollama_batch_embeddings:model %s batch size: %s', model, len(texts)) form_data = {'input': texts, 'model': model, 'truncate': True} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {key}', - } + headers = get_json_bearer_headers(key) if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) @@ -1139,7 +1147,7 @@ def get_embedding_function( batches = [query[i : i + embedding_batch_size] for i in range(0, len(query), embedding_batch_size)] if enable_async: - log.debug(f'generate_multiple_async: Processing {len(batches)} batches in parallel') + log.debug('generate_multiple_async: Processing %s batches in parallel', len(batches)) # Use semaphore to limit concurrent embedding API requests # 0 = unlimited (no semaphore) if concurrent_requests: @@ -1154,7 +1162,7 @@ def get_embedding_function( tasks = [embedding_function(batch, prefix=prefix, user=user) for batch in batches] batch_results = await asyncio.gather(*tasks) else: - log.debug(f'generate_multiple_async: Processing {len(batches)} batches sequentially') + log.debug('generate_multiple_async: Processing %s batches sequentially', len(batches)) batch_results = [] for batch in batches: batch_results.append(await embedding_function(batch, prefix=prefix, user=user)) @@ -1167,7 +1175,9 @@ def get_embedding_function( embeddings.extend(batch_embeddings) log.debug( - f'generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches' + 'generate_multiple_async: Generated %s embeddings from %s parallel batches', + len(embeddings), + len(batches), ) return embeddings else: @@ -1415,8 +1425,21 @@ async def get_sources_from_items( elif item.get('type') == 'chat': # Chat Attached chat = await Chats.get_chat_by_id(item.get('id')) + has_read_access = bool(chat and (user.role == 'admin' or chat.user_id == user.id)) - if chat and (user.role == 'admin' or chat.user_id == user.id): + if chat and not has_read_access: + has_read_access = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=chat.id, + permission='read', + ) + + if chat and not has_read_access and chat.folder_id: + folder = await Folders.get_folder_by_id(chat.folder_id) + has_read_access = folder and await has_folder_access(user.id, folder, 'read', db=None) + + if has_read_access: messages_map = chat.chat.get('history', {}).get('messages', {}) message_id = chat.chat.get('history', {}).get('currentId') @@ -1607,14 +1630,14 @@ async def get_sources_from_items( if query_result is None and collection_names: collection_names = set(collection_names).difference(extracted_collections) if not collection_names: - log.debug(f'skipping {item} as it has already been extracted') + log.debug('skipping %s as it has already been extracted', item) continue # Filter out collections the user cannot read if user and (item.get('type'), item.get('id')) not in folder_items: collection_names = await filter_accessible_collections(collection_names, user) if not collection_names: - log.debug(f'access denied for all collections in item {item}') + log.debug('access denied for all collections in item %s', item) continue try: @@ -1673,8 +1696,8 @@ def get_model_path(model: str, update_model: bool = False): 'local_files_only': local_files_only, } - log.debug(f'model: {model}') - log.debug(f'snapshot_kwargs: {snapshot_kwargs}') + log.debug('model: %s', model) + log.debug('snapshot_kwargs: %s', snapshot_kwargs) # Inspiration from upstream sentence_transformers if os.path.exists(model) or ('\\' in model or model.count('/') > 1) and local_files_only: @@ -1689,7 +1712,7 @@ def get_model_path(model: str, update_model: bool = False): # Attempt to query the huggingface_hub library to determine the local path and/or to update try: model_repo_path = snapshot_download(**snapshot_kwargs) - log.debug(f'model_repo_path: {model_repo_path}') + log.debug('model_repo_path: %s', model_repo_path) return model_repo_path except Exception as e: log.exception(f'Cannot determine model snapshot path: {e}') diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py index 9780fcac60..f6629164ff 100755 --- a/backend/open_webui/retrieval/vector/dbs/chroma.py +++ b/backend/open_webui/retrieval/vector/dbs/chroma.py @@ -182,7 +182,7 @@ class ChromaClient(VectorDBBase): collection.delete(where=filter) except Exception as e: # If collection doesn't exist, that's fine - nothing to delete - log.debug(f'Attempted to delete from non-existent collection {collection_name}. Ignoring.') + log.debug('Attempted to delete from non-existent collection %s. Ignoring.', collection_name) pass def reset(self): diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py index da3e6e93c8..f8178bdbac 100644 --- a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -3,7 +3,7 @@ NOTE: This vector database integration is community-supported and maintained on """ import ssl -from typing import Optional +from typing import Any, Optional from elasticsearch import BadRequestError, Elasticsearch from elasticsearch.helpers import bulk, scan @@ -23,7 +23,13 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) -from open_webui.retrieval.vector.utils import process_metadata +from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata + + +def _metadata_filter(key: str, op: str, value: Any) -> dict: + if op == '$in': + return {'terms': {f'metadata.{key}': value}} + return {'term': {f'metadata.{key}': value}} class ElasticsearchClient(VectorDBBase): @@ -161,12 +167,16 @@ class ElasticsearchClient(VectorDBBase): filter: Optional[dict] = None, limit: int = 10, ) -> Optional[SearchResult]: + filters = [{'term': {'collection': collection_name}}] + if filter: + filters.extend(_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter)) + query = { 'size': limit, '_source': ['text', 'metadata'], 'query': { 'script_score': { - 'query': {'bool': {'filter': [{'term': {'collection': collection_name}}]}}, + 'query': {'bool': {'filter': filters}}, 'script': { 'source': "cosineSimilarity(params.vector, 'vector') + 1.0", 'params': {'vector': vectors[0]}, # Assuming single query vector diff --git a/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py b/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py index a8cf62f7b1..bcecf89802 100644 --- a/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py +++ b/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py @@ -5,7 +5,6 @@ NOTE: This vector database integration is community-supported and maintained on from __future__ import annotations import array -import json import logging import math import re @@ -30,6 +29,7 @@ from open_webui.retrieval.vector.main import ( VectorItem, ) from open_webui.retrieval.vector.utils import process_metadata +from open_webui.utils.json_codec import JSONCodec from sqlalchemy import create_engine from sqlalchemy.pool import NullPool, QueuePool @@ -72,7 +72,7 @@ def _safe_json(v: Any) -> Dict[str, Any]: return {} if isinstance(v, str): try: - j = json.loads(v) + j = JSONCodec.loads(v) return j if isinstance(j, dict) else {} except Exception: return {} @@ -324,7 +324,7 @@ class MariaDBVectorClient(VectorDBBase): emb, collection_name, item.get('text'), - json.dumps(meta), + JSONCodec.dumps(meta), ) ) cur.executemany(sql, params) @@ -367,7 +367,7 @@ class MariaDBVectorClient(VectorDBBase): emb, collection_name, item.get('text'), - json.dumps(meta), + JSONCodec.dumps(meta), ) ) cur.executemany(sql, params) diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index fa2abe85d7..d09f01c16d 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -2,9 +2,9 @@ NOTE: This vector database integration is community-supported and maintained on a best-effort basis. """ -import json import logging -from typing import Optional +import re +from typing import Any, Optional from open_webui.config import ( MILVUS_DB, @@ -24,7 +24,8 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) -from open_webui.retrieval.vector.utils import process_metadata +from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata +from open_webui.utils.json_codec import JSONCodec from pymilvus import DataType from pymilvus import MilvusClient as Client from pymilvus.exceptions import MilvusException @@ -35,6 +36,36 @@ log = logging.getLogger(__name__) # field). Clamp long chunks before insert so one oversized chunk can't fail the # whole batch and leave the file with zero embeddings. MILVUS_TEXT_MAX_LENGTH = 65535 +_SAFE_METADATA_KEY_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$') + + +def _escape_milvus_string(value: str) -> str: + if not isinstance(value, str): + raise TypeError(f'Expected str, got {type(value).__name__}') + return value.replace('\\', '\\\\').replace("'", "\\'") + + +def _milvus_literal(value: Any) -> str: + if isinstance(value, str): + return f"'{_escape_milvus_string(value)}'" + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, (int, float)): + return str(value) + raise TypeError(f'Unsupported Milvus filter value type: {type(value).__name__}') + + +def _metadata_exprs(filter: Optional[dict]) -> list[str]: + exprs = [] + for key, op, value in iter_filter_conditions(filter): + if not isinstance(key, str) or not _SAFE_METADATA_KEY_RE.fullmatch(key): + raise ValueError(f'Invalid Milvus metadata filter key: {key!r}') + if op == '$in': + items = [f"metadata['{key}'] == {_milvus_literal(item)}" for item in value] + exprs.append(f'({" or ".join(items)})' if items else 'false') + else: + exprs.append(f"metadata['{key}'] == {_milvus_literal(value)}") + return exprs class MilvusClient(VectorDBBase): @@ -125,7 +156,7 @@ class MilvusClient(VectorDBBase): index_type = MILVUS_INDEX_TYPE.upper() metric_type = MILVUS_METRIC_TYPE.upper() - log.info(f'Using Milvus index type: {index_type}, metric type: {metric_type}') + log.info('Using Milvus index type: %s, metric type: %s', index_type, metric_type) index_creation_params = {} if index_type == 'HNSW': @@ -133,18 +164,18 @@ class MilvusClient(VectorDBBase): 'M': MILVUS_HNSW_M, 'efConstruction': MILVUS_HNSW_EFCONSTRUCTION, } - log.info(f'HNSW params: {index_creation_params}') + log.info('HNSW params: %s', index_creation_params) elif index_type == 'IVF_FLAT': index_creation_params = {'nlist': MILVUS_IVF_FLAT_NLIST} - log.info(f'IVF_FLAT params: {index_creation_params}') + log.info('IVF_FLAT params: %s', index_creation_params) elif index_type == 'DISKANN': index_creation_params = { 'max_degree': MILVUS_DISKANN_MAX_DEGREE, 'search_list_size': MILVUS_DISKANN_SEARCH_LIST_SIZE, } - log.info(f'DISKANN params: {index_creation_params}') + log.info('DISKANN params: %s', index_creation_params) elif index_type in ['FLAT', 'AUTOINDEX']: - log.info(f'Using {index_type} index with no specific build-time params.') + log.info('Using %s index with no specific build-time params.', index_type) else: log.warning( f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. " @@ -167,7 +198,11 @@ class MilvusClient(VectorDBBase): index_params=index_params, ) log.info( - f"Successfully created collection '{self.collection_prefix}_{collection_name}' with index type '{index_type}' and metric '{metric_type}'." + "Successfully created collection '%s_%s' with index type '%s' and metric '%s'.", + self.collection_prefix, + collection_name, + index_type, + metric_type, ) def has_collection(self, collection_name: str) -> bool: @@ -189,6 +224,9 @@ class MilvusClient(VectorDBBase): ) -> Optional[SearchResult]: # Search for the nearest neighbor items based on the vectors and return 'limit' number of results. collection_name = collection_name.replace('-', '_') + kwargs = {} + if filter: + kwargs['filter'] = ' and '.join(_metadata_exprs(filter)) # For some index types like IVF_FLAT, search params like nprobe can be set. # Example: search_params = {"nprobe": 10} if using IVF_FLAT # For simplicity, not adding configurable search_params here, but could be extended. @@ -197,6 +235,7 @@ class MilvusClient(VectorDBBase): data=vectors, limit=limit, output_fields=['data', 'metadata'], + **kwargs, # search_params=search_params # Potentially add later if needed ) return self._result_to_search_result(result) @@ -220,7 +259,11 @@ class MilvusClient(VectorDBBase): try: log.info( - f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}" + "Querying collection %s_%s with filter: '%s', limit: %s", + self.collection_prefix, + collection_name, + filter_string, + limit, ) iterator = self.client.query_iterator( @@ -242,7 +285,7 @@ class MilvusClient(VectorDBBase): break all_results.extend(batch) - log.debug(f'Total results from query: {len(all_results)}') + log.debug('Total results from query: %s', len(all_results)) return self._result_to_get_result([all_results] if all_results else [[]]) except Exception as e: @@ -265,7 +308,7 @@ class MilvusClient(VectorDBBase): # Insert the items into the collection, if the collection does not exist, it will be created. collection_name = collection_name.replace('-', '_') if not self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}'): - log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist. Creating now.') + log.info('Collection %s_%s does not exist. Creating now.', self.collection_prefix, collection_name) if not items: log.error( f'Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension.' @@ -273,7 +316,7 @@ class MilvusClient(VectorDBBase): raise ValueError('Cannot create Milvus collection without items to determine vector dimension.') self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector'])) - log.info(f'Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.') + log.info('Inserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name) data = [] for item in items: text = item['text'] or '' @@ -301,7 +344,9 @@ class MilvusClient(VectorDBBase): # Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created. collection_name = collection_name.replace('-', '_') if not self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}'): - log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now.') + log.info( + 'Collection %s_%s does not exist for upsert. Creating now.', self.collection_prefix, collection_name + ) if not items: log.error( f'Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension.' @@ -311,7 +356,7 @@ class MilvusClient(VectorDBBase): ) self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector'])) - log.info(f'Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.') + log.info('Upserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name) data = [] for item in items: text = item['text'] or '' @@ -348,15 +393,20 @@ class MilvusClient(VectorDBBase): return None if ids: - log.info(f'Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}') + log.info('Deleting items by IDs from %s_%s. IDs: %s', self.collection_prefix, collection_name, ids) return self.client.delete( collection_name=f'{self.collection_prefix}_{collection_name}', ids=ids, ) elif filter: - filter_string = ' && '.join([f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items()]) + filter_string = ' && '.join( + [f'metadata["{key}"] == {JSONCodec.dumps(value)}' for key, value in filter.items()] + ) log.info( - f'Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}' + 'Deleting items by filter from %s_%s. Filter: %s', + self.collection_prefix, + collection_name, + filter_string, ) return self.client.delete( collection_name=f'{self.collection_prefix}_{collection_name}', @@ -378,7 +428,7 @@ class MilvusClient(VectorDBBase): try: self.client.drop_collection(collection_name=collection_name_full) deleted_collections.append(collection_name_full) - log.info(f'Deleted collection: {collection_name_full}') + log.info('Deleted collection: %s', collection_name_full) except Exception as e: log.error(f'Error deleting collection {collection_name_full}: {e}') - log.info(f'Milvus reset complete. Deleted collections: {deleted_collections}') + log.info('Milvus reset complete. Deleted collections: %s', deleted_collections) diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index 599cb8712c..24f1864fd4 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py @@ -17,6 +17,7 @@ from open_webui.config import ( MILVUS_TOKEN, MILVUS_URI, ) +from open_webui.retrieval.vector.dbs.milvus import _metadata_exprs from open_webui.retrieval.vector.main import ( GetResult, SearchResult, @@ -146,7 +147,7 @@ class MilvusClient(VectorDBBase): # The index only accelerates resource_id filters; never fail # collection creation over it. log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}') - log.info(f'Created shared collection: {mt_collection_name}') + log.info('Created shared collection: %s', mt_collection_name) def _ensure_collection(self, mt_collection_name: str, dimension: int): if not self.client.has_collection(mt_collection_name): @@ -221,13 +222,14 @@ class MilvusClient(VectorDBBase): self.client.load_collection(mt_collection) + expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'", *_metadata_exprs(filter)] results = self.client.search( collection_name=mt_collection, data=vectors, anns_field='vector', search_params={'metric_type': MILVUS_METRIC_TYPE, 'params': {}}, limit=limit, - filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'", + filter=' and '.join(expr), output_fields=['id', 'text', 'metadata'], ) diff --git a/backend/open_webui/retrieval/vector/dbs/opengauss.py b/backend/open_webui/retrieval/vector/dbs/opengauss.py index 1c9d35253c..cfb7166250 100644 --- a/backend/open_webui/retrieval/vector/dbs/opengauss.py +++ b/backend/open_webui/retrieval/vector/dbs/opengauss.py @@ -2,7 +2,6 @@ NOTE: This vector database integration is community-supported and maintained on a best-effort basis. """ -import json import logging import re from typing import Any, Dict, List, Optional @@ -63,20 +62,18 @@ from open_webui.config import ( OPENGAUSS_POOL_SIZE, OPENGAUSS_POOL_TIMEOUT, ) -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.vector.main import ( GetResult, SearchResult, VectorDBBase, VectorItem, ) -from open_webui.retrieval.vector.utils import process_metadata +from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata VECTOR_LENGTH = OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH Base = declarative_base() log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS['RAG']) class DocumentChunk(Base): @@ -89,6 +86,12 @@ class DocumentChunk(Base): vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True) +def _metadata_clause(key: str, op: str, value: Any): + if op == '$in': + return DocumentChunk.vmetadata[key].astext.in_([str(v) for v in value]) + return DocumentChunk.vmetadata[key].astext == str(value) + + class OpenGaussClient(VectorDBBase): def __init__(self) -> None: if not OPENGAUSS_DB_URL: @@ -182,7 +185,7 @@ class OpenGaussClient(VectorDBBase): new_items.append(new_chunk) self.session.bulk_save_objects(new_items) self.session.commit() - log.info(f"Inserting {len(new_items)} items into collection '{collection_name}'.") + log.info("Inserting %s items into collection '%s'.", len(new_items), collection_name) except Exception as e: self.session.rollback() log.exception(f'Failed to insert data: {e}') @@ -208,7 +211,7 @@ class OpenGaussClient(VectorDBBase): ) self.session.add(new_chunk) self.session.commit() - log.info(f"Inserting/updating {len(items)} items in collection '{collection_name}'.") + log.info("Inserting/updating %s items in collection '%s'.", len(items), collection_name) except Exception as e: self.session.rollback() log.exception(f'Failed to insert or update data.: {e}') @@ -245,10 +248,15 @@ class OpenGaussClient(VectorDBBase): DocumentChunk.vmetadata, (DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label('distance'), ] + where_clauses = [DocumentChunk.collection_name == collection_name] + if filter: + where_clauses.extend( + _metadata_clause(key, op, value) for key, op, value in iter_filter_conditions(filter) + ) subq = ( select(*result_fields) - .where(DocumentChunk.collection_name == collection_name) + .where(*where_clauses) .order_by(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)) ) if limit is not None: @@ -353,7 +361,7 @@ class OpenGaussClient(VectorDBBase): query = query.filter(DocumentChunk.vmetadata[key].astext == str(value)) deleted = query.delete(synchronize_session=False) self.session.commit() - log.info(f"Deleted {deleted} items from collection '{collection_name}'") + log.info("Deleted %s items from collection '%s'", deleted, collection_name) except Exception as e: self.session.rollback() log.exception(f'Failed to delete data: {e}') @@ -363,7 +371,7 @@ class OpenGaussClient(VectorDBBase): try: deleted = self.session.query(DocumentChunk).delete() self.session.commit() - log.info(f'Reset completed. Deleted {deleted} items') + log.info('Reset completed. Deleted %s items', deleted) except Exception as e: self.session.rollback() log.exception(f'Reset failed: {e}') @@ -387,4 +395,4 @@ class OpenGaussClient(VectorDBBase): def delete_collection(self, collection_name: str) -> None: self.delete(collection_name) - log.info(f"Collection '{collection_name}' has been deleted") + log.info("Collection '%s' has been deleted", collection_name) diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index 798802cd54..48c538a10d 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -2,7 +2,7 @@ NOTE: This vector database integration is community-supported and maintained on a best-effort basis. """ -from typing import Optional +from typing import Any, Optional from open_webui.config import ( OPENSEARCH_CERT_VERIFY, @@ -17,11 +17,17 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) -from open_webui.retrieval.vector.utils import process_metadata +from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata from opensearchpy import OpenSearch from opensearchpy.helpers import bulk +def _metadata_filter(key: str, op: str, value: Any) -> dict: + if op == '$in': + return {'terms': {f'metadata.{key}.keyword': value}} + return {'term': {f'metadata.{key}.keyword': value}} + + class OpenSearchClient(VectorDBBase): def __init__(self): self.index_prefix = 'open_webui' @@ -121,6 +127,8 @@ class OpenSearchClient(VectorDBBase): filter: Optional[dict] = None, limit: int = 10, ) -> Optional[SearchResult]: + filter_clauses = [_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter)] + try: if not self.has_collection(collection_name): return None @@ -130,7 +138,7 @@ class OpenSearchClient(VectorDBBase): '_source': ['text', 'metadata'], 'query': { 'script_score': { - 'query': {'match_all': {}}, + 'query': {'bool': {'filter': filter_clauses}} if filter_clauses else {'match_all': {}}, 'script': { 'source': '(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0', 'params': { diff --git a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py index b09eacb81d..77408081d5 100644 --- a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py +++ b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py @@ -32,6 +32,7 @@ import array import json import logging import os +import re import threading import time from decimal import Decimal @@ -56,8 +57,29 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) +from open_webui.retrieval.vector.utils import iter_filter_conditions +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) +_SAFE_METADATA_KEY_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$') + + +def _metadata_where(filter: Optional[dict]) -> tuple[str, dict[str, Any]]: + clause = '' + params: dict[str, Any] = {} + for i, (key, op, value) in enumerate(iter_filter_conditions(filter)): + if not isinstance(key, str) or not _SAFE_METADATA_KEY_RE.fullmatch(key): + raise ValueError(f'Invalid Oracle metadata filter key: {key!r}') + json_value = f"JSON_VALUE(dc.vmetadata, '$.{key}' RETURNING VARCHAR2(4096))" + if op == '$in': + names = [f'value_{i}_{j}' for j, _ in enumerate(value)] + clause += f' AND {json_value} IN ({", ".join(f":{name}" for name in names)})' if names else ' AND 1 = 0' + params.update({name: str(item) for name, item in zip(names, value)}) + else: + name = f'value_{i}' + clause += f' AND {json_value} = :{name}' + params[name] = str(value) + return clause, params class Oracle23aiClient(VectorDBBase): @@ -93,10 +115,10 @@ class Oracle23aiClient(VectorDBBase): self._create_dbcs_pool() dsn = ORACLE_DB_DSN - log.info(f'Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]') + log.info('Creating Connection Pool [%s:**@%s]', ORACLE_DB_USER, dsn) with self.get_connection() as connection: - log.info(f'Connection version: {connection.version}') + log.info('Connection version: %s', connection.version) self._initialize_database(connection) log.info('Oracle Vector Search initialization complete.') @@ -158,7 +180,7 @@ class Oracle23aiClient(VectorDBBase): if attempt < max_retries - 1: wait_time = 2**attempt - log.info(f'Retrying in {wait_time} seconds...') + log.info('Retrying in %s seconds...', wait_time) time.sleep(wait_time) else: raise @@ -183,7 +205,7 @@ class Oracle23aiClient(VectorDBBase): thread = threading.Thread(target=_monitor, daemon=True) thread.start() - log.info(f'Started DB health monitor every {interval_seconds} seconds.') + log.info('Started DB health monitor every %s seconds.', interval_seconds) def _reconnect_pool(self): """ @@ -390,7 +412,7 @@ class Oracle23aiClient(VectorDBBase): Returns: Dict: Metadata dictionary """ - return json.loads(json_str) if json_str else {} + return JSONCodec.loads(json_str) if json_str else {} def insert(self, collection_name: str, items: List[VectorItem]) -> None: """ @@ -411,7 +433,7 @@ class Oracle23aiClient(VectorDBBase): ... ] >>> client.insert("my_collection", items) """ - log.info(f"Inserting {len(items)} items into collection '{collection_name}'.") + log.info("Inserting %s items into collection '%s'.", len(items), collection_name) with self.get_connection() as connection: try: @@ -436,7 +458,7 @@ class Oracle23aiClient(VectorDBBase): ) connection.commit() - log.info(f"Successfully inserted {len(items)} items into collection '{collection_name}'.") + log.info("Successfully inserted %s items into collection '%s'.", len(items), collection_name) except Exception as e: connection.rollback() @@ -465,7 +487,7 @@ class Oracle23aiClient(VectorDBBase): ... ] >>> client.upsert("my_collection", items) """ - log.info(f"Upserting {len(items)} items into collection '{collection_name}'.") + log.info("Upserting %s items into collection '%s'.", len(items), collection_name) with self.get_connection() as connection: try: @@ -504,7 +526,7 @@ class Oracle23aiClient(VectorDBBase): ) connection.commit() - log.info(f"Successfully upserted {len(items)} items into collection '{collection_name}'.") + log.info("Successfully upserted %s items into collection '%s'.", len(items), collection_name) except Exception as e: connection.rollback() @@ -540,7 +562,7 @@ class Oracle23aiClient(VectorDBBase): ... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])): ... log.info(f"Match {i+1}: id={id}, distance={dist}") """ - log.info(f"Searching items from collection '{collection_name}' with limit {limit}.") + log.info("Searching items from collection '%s' with limit %s.", collection_name, limit) try: if not vectors: @@ -548,6 +570,7 @@ class Oracle23aiClient(VectorDBBase): return None num_queries = len(vectors) + filter_clause, filter_params = _metadata_where(filter) ids = [[] for _ in range(num_queries)] distances = [[] for _ in range(num_queries)] @@ -560,12 +583,12 @@ class Oracle23aiClient(VectorDBBase): vector_blob = self._vector_to_blob(vector) cursor.execute( - """ - SELECT dc.id, dc.text, + f""" + SELECT dc.id, dc.text, JSON_SERIALIZE(dc.vmetadata RETURNING VARCHAR2(4096)) as vmetadata, VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) as distance FROM document_chunk dc - WHERE dc.collection_name = :collection_name + WHERE dc.collection_name = :collection_name{filter_clause} ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) FETCH APPROX FIRST :limit ROWS ONLY """, @@ -573,6 +596,7 @@ class Oracle23aiClient(VectorDBBase): 'query_vector': vector_blob, 'collection_name': collection_name, 'limit': limit, + **filter_params, }, ) @@ -586,7 +610,7 @@ class Oracle23aiClient(VectorDBBase): metadatas[qid].append(self._json_to_metadata(metadata_str)) distances[qid].append(float(row[3])) - log.info(f'Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results.') + log.info('Search completed. Found %s total results.', sum(len(ids[i]) for i in range(num_queries))) return SearchResult(ids=ids, distances=distances, documents=documents, metadatas=metadatas) @@ -615,7 +639,7 @@ class Oracle23aiClient(VectorDBBase): >>> if results: ... print(f"Found {len(results.ids[0])} matching documents") """ - log.info(f"Querying items from collection '{collection_name}' with filters.") + log.info("Querying items from collection '%s' with filters.", collection_name) try: limit = limit or 100 @@ -655,7 +679,7 @@ class Oracle23aiClient(VectorDBBase): ] ] - log.info(f'Query completed. Found {len(results)} results.') + log.info('Query completed. Found %s results.', len(results)) return GetResult(ids=ids, documents=documents, metadatas=metadatas) @@ -746,7 +770,7 @@ class Oracle23aiClient(VectorDBBase): >>> # Or delete by metadata filter >>> client.delete("my_collection", filter={"source": "deprecated_source"}) """ - log.info(f"Deleting items from collection '{collection_name}'.") + log.info("Deleting items from collection '%s'.", collection_name) try: query = 'DELETE FROM document_chunk WHERE collection_name = :collection_name' @@ -771,7 +795,7 @@ class Oracle23aiClient(VectorDBBase): deleted = cursor.rowcount connection.commit() - log.info(f"Deleted {deleted} items from collection '{collection_name}'.") + log.info("Deleted %s items from collection '%s'.", deleted, collection_name) except Exception as e: log.exception(f'Error during delete: {e}') @@ -799,7 +823,7 @@ class Oracle23aiClient(VectorDBBase): deleted = cursor.rowcount connection.commit() - log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.") + log.info("Reset complete. Deleted %s items from 'document_chunk' table.", deleted) except Exception as e: log.exception(f'Error during reset: {e}') @@ -874,7 +898,7 @@ class Oracle23aiClient(VectorDBBase): >>> client = Oracle23aiClient() >>> client.delete_collection("obsolete_collection") """ - log.info(f"Deleting collection '{collection_name}'.") + log.info("Deleting collection '%s'.", collection_name) try: with self.get_connection() as connection: @@ -890,7 +914,7 @@ class Oracle23aiClient(VectorDBBase): deleted = cursor.rowcount connection.commit() - log.info(f"Collection '{collection_name}' deleted. Removed {deleted} items.") + log.info("Collection '%s' deleted. Removed %s items.", collection_name, deleted) except Exception as e: log.exception(f"Error deleting collection '{collection_name}': {e}") diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index b37d774f72..75daeccd12 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -1,4 +1,3 @@ -import json import logging from typing import Any, Dict, List, Optional, Tuple @@ -18,6 +17,7 @@ from open_webui.config import ( PGVECTOR_POOL_TIMEOUT, PGVECTOR_USE_HALFVEC, ) +from open_webui.internal.db import ScopedSession, enable_iam_token_auth from open_webui.retrieval.vector.main import ( GetResult, SearchResult, @@ -25,6 +25,7 @@ from open_webui.retrieval.vector.main import ( VectorItem, ) from open_webui.retrieval.vector.utils import merge_hybrid_search_results, process_metadata +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import sanitize_text_for_db from pgvector.sqlalchemy import HALFVEC, Vector from sqlalchemy import ( @@ -87,8 +88,6 @@ class PgvectorClient(VectorDBBase): def __init__(self) -> None: # if no pgvector uri, use the existing database connection if not PGVECTOR_DB_URL: - from open_webui.internal.db import ScopedSession - self.session = ScopedSession else: if isinstance(PGVECTOR_POOL_SIZE, int): @@ -107,6 +106,7 @@ class PgvectorClient(VectorDBBase): else: engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True) + enable_iam_token_auth(engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) self.session = scoped_session(SessionLocal) @@ -303,7 +303,7 @@ class PgvectorClient(VectorDBBase): # Use raw SQL for BYTEA/pgcrypto # Ensure metadata is converted to its JSON text representation # Sanitize to strip null bytes / surrogates that PostgreSQL cannot store - json_metadata = sanitize_text_for_db(json.dumps(item['metadata'])) + json_metadata = sanitize_text_for_db(JSONCodec.dumps(item['metadata'])) item_text = sanitize_text_for_db(item['text']) self.session.execute( text(""" @@ -326,7 +326,7 @@ class PgvectorClient(VectorDBBase): }, ) self.session.commit() - log.info(f"Encrypted & inserted {len(items)} into '{collection_name}'") + log.info("Encrypted & inserted %s into '%s'", len(items), collection_name) else: new_items = [] @@ -342,7 +342,7 @@ class PgvectorClient(VectorDBBase): new_items.append(new_chunk) self.session.bulk_save_objects(new_items) self.session.commit() - log.info(f"Inserted {len(new_items)} items into collection '{collection_name}'.") + log.info("Inserted %s items into collection '%s'.", len(new_items), collection_name) except Exception as e: self.session.rollback() log.exception(f'Error during insert: {e}') @@ -354,7 +354,7 @@ class PgvectorClient(VectorDBBase): for item in items: vector = self.adjust_vector_length(item['vector']) # Sanitize to strip null bytes / surrogates that PostgreSQL cannot store - json_metadata = sanitize_text_for_db(json.dumps(item['metadata'])) + json_metadata = sanitize_text_for_db(JSONCodec.dumps(item['metadata'])) item_text = sanitize_text_for_db(item['text']) self.session.execute( text(""" @@ -381,7 +381,7 @@ class PgvectorClient(VectorDBBase): }, ) self.session.commit() - log.info(f"Encrypted & upserted {len(items)} into '{collection_name}'") + log.info("Encrypted & upserted %s into '%s'", len(items), collection_name) else: for item in items: vector = self.adjust_vector_length(item['vector']) @@ -401,7 +401,7 @@ class PgvectorClient(VectorDBBase): ) self.session.add(new_chunk) self.session.commit() - log.info(f"Upserted {len(items)} items into collection '{collection_name}'.") + log.info("Upserted %s items into collection '%s'.", len(items), collection_name) except Exception as e: self.session.rollback() log.exception(f'Error during upsert: {e}') @@ -712,7 +712,7 @@ class PgvectorClient(VectorDBBase): query = query.filter(DocumentChunk.vmetadata[key].astext == str(value)) deleted = query.delete(synchronize_session=False) self.session.commit() - log.info(f"Deleted {deleted} items from collection '{collection_name}'.") + log.info("Deleted %s items from collection '%s'.", deleted, collection_name) except Exception as e: self.session.rollback() log.exception(f'Error during delete: {e}') @@ -722,7 +722,7 @@ class PgvectorClient(VectorDBBase): try: deleted = self.session.query(DocumentChunk).delete() self.session.commit() - log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.") + log.info("Reset complete. Deleted %s items from 'document_chunk' table.", deleted) except Exception as e: self.session.rollback() log.exception(f'Error during reset: {e}') @@ -746,4 +746,4 @@ class PgvectorClient(VectorDBBase): def delete_collection(self, collection_name: str) -> None: self.delete(collection_name) - log.info(f"Collection '{collection_name}' deleted.") + log.info("Collection '%s' deleted.", collection_name) diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py index 7e2b6e2dfa..d1b7363839 100644 --- a/backend/open_webui/retrieval/vector/dbs/pinecone.py +++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py @@ -35,7 +35,7 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) -from open_webui.retrieval.vector.utils import process_metadata +from open_webui.retrieval.vector.utils import normalize_filter, process_metadata NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system BATCH_SIZE = 100 # Recommended batch size for Pinecone operations @@ -106,16 +106,16 @@ class PineconeClient(VectorDBBase): try: # Check if index exists if self.index_name not in self.client.list_indexes().names(): - log.info(f"Creating Pinecone index '{self.index_name}'...") + log.info("Creating Pinecone index '%s'...", self.index_name) self.client.create_index( name=self.index_name, dimension=self.dimension, metric=self.metric, spec=ServerlessSpec(cloud=self.cloud, region=self.environment), ) - log.info(f"Successfully created Pinecone index '{self.index_name}'") + log.info("Successfully created Pinecone index '%s'", self.index_name) else: - log.info(f"Using existing Pinecone index '{self.index_name}'") + log.info("Using existing Pinecone index '%s'", self.index_name) # Connect to the index self.index = self.client.Index( @@ -245,7 +245,7 @@ class PineconeClient(VectorDBBase): collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) try: self.index.delete(filter={'collection_name': collection_name_with_prefix}) - log.info(f"Collection '{collection_name_with_prefix}' deleted (all vectors removed).") + log.info("Collection '%s' deleted (all vectors removed).", collection_name_with_prefix) except Exception as e: log.warning(f"Failed to delete collection '{collection_name_with_prefix}': {e}") raise @@ -274,9 +274,9 @@ class PineconeClient(VectorDBBase): log.error(f'Error inserting batch: {e}') raise elapsed = time.time() - start_time - log.debug(f'Insert of {len(points)} vectors took {elapsed:.2f} seconds') + log.debug('Insert of %s vectors took %.2f seconds', len(points), elapsed) log.info( - f"Successfully inserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'" + "Successfully inserted %s vectors in parallel batches into '%s'", len(points), collection_name_with_prefix ) def upsert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -303,9 +303,9 @@ class PineconeClient(VectorDBBase): log.error(f'Error upserting batch: {e}') raise elapsed = time.time() - start_time - log.debug(f'Upsert of {len(points)} vectors took {elapsed:.2f} seconds') + log.debug('Upsert of %s vectors took %.2f seconds', len(points), elapsed) log.info( - f"Successfully upserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'" + "Successfully upserted %s vectors in parallel batches into '%s'", len(points), collection_name_with_prefix ) async def insert_async(self, collection_name: str, items: List[VectorItem]) -> None: @@ -326,7 +326,9 @@ class PineconeClient(VectorDBBase): if isinstance(result, Exception): log.error(f'Error in async insert batch: {result}') raise result - log.info(f"Successfully async inserted {len(points)} vectors in batches into '{collection_name_with_prefix}'") + log.info( + "Successfully async inserted %s vectors in batches into '%s'", len(points), collection_name_with_prefix + ) async def upsert_async(self, collection_name: str, items: List[VectorItem]) -> None: """Async version of upsert using asyncio and run_in_executor for improved performance.""" @@ -346,7 +348,9 @@ class PineconeClient(VectorDBBase): if isinstance(result, Exception): log.error(f'Error in async upsert batch: {result}') raise result - log.info(f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'") + log.info( + "Successfully async upserted %s vectors in batches into '%s'", len(points), collection_name_with_prefix + ) def search( self, @@ -368,13 +372,15 @@ class PineconeClient(VectorDBBase): try: # Search using the first vector (assuming this is the intended behavior) query_vector = vectors[0] + pinecone_filter = normalize_filter(filter) + pinecone_filter['collection_name'] = collection_name_with_prefix # Perform the search query_response = self.index.query( vector=query_vector, top_k=limit, include_metadata=True, - filter={'collection_name': collection_name_with_prefix}, + filter=pinecone_filter, ) matches = getattr(query_response, 'matches', []) or [] @@ -474,8 +480,10 @@ class PineconeClient(VectorDBBase): # Note: When deleting by ID, we can't filter by collection_name # This is a limitation of Pinecone - be careful with ID uniqueness self.index.delete(ids=batch_ids) - log.debug(f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'") - log.info(f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'") + log.debug( + "Deleted batch of %s vectors by ID from '%s'", len(batch_ids), collection_name_with_prefix + ) + log.info("Successfully deleted %s vectors by ID from '%s'", len(ids), collection_name_with_prefix) elif filter: # Combine user filter with collection_name @@ -484,7 +492,7 @@ class PineconeClient(VectorDBBase): pinecone_filter.update(filter) # Delete by metadata filter self.index.delete(filter=pinecone_filter) - log.info(f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'") + log.info("Successfully deleted vectors by filter from '%s'", collection_name_with_prefix) else: log.warning('No ids or filter provided for delete operation') diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index 7156a16c53..e683bb40c2 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -3,7 +3,7 @@ NOTE: This vector database integration is community-supported and maintained on """ import logging -from typing import Optional +from typing import Any, Optional from urllib.parse import urlparse from open_webui.config import ( @@ -22,6 +22,7 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) +from open_webui.retrieval.vector.utils import iter_filter_conditions from qdrant_client import QdrantClient as Qclient from qdrant_client.http.models import PointStruct from qdrant_client.models import models @@ -31,6 +32,11 @@ NO_LIMIT = 999999999 log = logging.getLogger(__name__) +def _metadata_filter(key: str, op: str, value: Any) -> models.FieldCondition: + match = models.MatchAny(any=value) if op == '$in' else models.MatchValue(value=value) + return models.FieldCondition(key=f'metadata.{key}', match=match) + + class QdrantClient(VectorDBBase): def __init__(self): self.collection_prefix = QDRANT_COLLECTION_PREFIX @@ -119,7 +125,7 @@ class QdrantClient(VectorDBBase): on_disk=self.QDRANT_ON_DISK, ), ) - log.info(f'collection {collection_name_with_prefix} successfully created!') + log.info('collection %s successfully created!', collection_name_with_prefix) def _create_collection_if_not_exists(self, collection_name, dimension): if not self.has_collection(collection_name=collection_name): @@ -152,10 +158,13 @@ class QdrantClient(VectorDBBase): if limit is None: limit = NO_LIMIT # otherwise qdrant would set limit to 10! + conditions = [_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter)] + query_filter = models.Filter(must=conditions) if conditions else None query_response = self.client.query_points( collection_name=f'{self.collection_prefix}_{collection_name}', query=vectors[0], limit=limit, + query_filter=query_filter, ) get_result = self._result_to_get_result(query_response.points) return SearchResult( diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py index 9b717644c5..00941d6bb0 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py @@ -23,6 +23,7 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) +from open_webui.retrieval.vector.utils import iter_filter_conditions from qdrant_client import QdrantClient as Qclient from qdrant_client.http.exceptions import UnexpectedResponse from qdrant_client.http.models import PointStruct @@ -39,8 +40,9 @@ def _tenant_filter(tenant_id: str) -> models.FieldCondition: return models.FieldCondition(key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id)) -def _metadata_filter(key: str, value: Any) -> models.FieldCondition: - return models.FieldCondition(key=f'metadata.{key}', match=models.MatchValue(value=value)) +def _metadata_filter(key: str, op: str, value: Any) -> models.FieldCondition: + match = models.MatchAny(any=value) if op == '$in' else models.MatchValue(value=value) + return models.FieldCondition(key=f'metadata.{key}', match=match) class QdrantClient(VectorDBBase): @@ -148,7 +150,7 @@ class QdrantClient(VectorDBBase): m=0, ), ) - log.info(f'Multi-tenant collection {mt_collection_name} created with dimension {dimension}!') + log.info('Multi-tenant collection %s created with dimension %s!', mt_collection_name, dimension) self.client.create_payload_index( collection_name=mt_collection_name, @@ -224,7 +226,7 @@ class QdrantClient(VectorDBBase): mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) if not self.client.collection_exists(collection_name=mt_collection): - log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete") + log.debug("Collection %s doesn't exist, nothing to delete", mt_collection) return None must_conditions = [_tenant_filter(tenant_id)] @@ -234,7 +236,7 @@ class QdrantClient(VectorDBBase): # whose payload omits an id (e.g. memories), leaving orphaned vectors. must_conditions.append(models.HasIdCondition(has_id=ids)) elif filter: - must_conditions += [_metadata_filter(k, v) for k, v in filter.items()] + must_conditions += [_metadata_filter(k, '$eq', v) for k, v in filter.items()] return self.client.delete( collection_name=mt_collection, @@ -255,15 +257,17 @@ class QdrantClient(VectorDBBase): return None mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) if not self.client.collection_exists(collection_name=mt_collection): - log.debug(f"Collection {mt_collection} doesn't exist, search returns None") + log.debug("Collection %s doesn't exist, search returns None", mt_collection) return None - tenant_filter = _tenant_filter(tenant_id) + conditions = [_tenant_filter(tenant_id)] + if filter: + conditions.extend(_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter)) query_response = self.client.query_points( collection_name=mt_collection, query=vectors[0], limit=limit, - query_filter=models.Filter(must=[tenant_filter]), + query_filter=models.Filter(must=conditions), ) get_result = self._result_to_get_result(query_response.points) return SearchResult( @@ -281,12 +285,12 @@ class QdrantClient(VectorDBBase): return None mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) if not self.client.collection_exists(collection_name=mt_collection): - log.debug(f"Collection {mt_collection} doesn't exist, query returns None") + log.debug("Collection %s doesn't exist, query returns None", mt_collection) return None if limit is None: limit = NO_LIMIT tenant_filter = _tenant_filter(tenant_id) - field_conditions = [_metadata_filter(k, v) for k, v in filter.items()] + field_conditions = [_metadata_filter(k, '$eq', v) for k, v in filter.items()] combined_filter = models.Filter(must=[tenant_filter, *field_conditions]) points = self.client.scroll( collection_name=mt_collection, @@ -303,7 +307,7 @@ class QdrantClient(VectorDBBase): return None mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) if not self.client.collection_exists(collection_name=mt_collection): - log.debug(f"Collection {mt_collection} doesn't exist, get returns None") + log.debug("Collection %s doesn't exist, get returns None", mt_collection) return None tenant_filter = _tenant_filter(tenant_id) points = self.client.scroll( @@ -350,7 +354,7 @@ class QdrantClient(VectorDBBase): return None mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) if not self.client.collection_exists(collection_name=mt_collection): - log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete") + log.debug("Collection %s doesn't exist, nothing to delete", mt_collection) return None self.client.delete( collection_name=mt_collection, diff --git a/backend/open_webui/retrieval/vector/dbs/s3vector.py b/backend/open_webui/retrieval/vector/dbs/s3vector.py index e0b156931c..5a92234f78 100644 --- a/backend/open_webui/retrieval/vector/dbs/s3vector.py +++ b/backend/open_webui/retrieval/vector/dbs/s3vector.py @@ -13,7 +13,7 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) -from open_webui.retrieval.vector.utils import process_metadata +from open_webui.retrieval.vector.utils import metadata_matches_filter, normalize_filter, process_metadata log = logging.getLogger(__name__) @@ -36,7 +36,7 @@ class S3VectorClient(VectorDBBase): if self.bucket_name and self.region: try: self.client = boto3.client('s3vectors', region_name=self.region) - log.info(f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'") + log.info("S3Vector client initialized for bucket '%s' in region '%s'", self.bucket_name, self.region) except Exception as e: log.error(f'Failed to initialize S3Vector client: {e}') self.client = None @@ -54,7 +54,7 @@ class S3VectorClient(VectorDBBase): Create a new index in the S3 vector bucket for the given collection if it does not exist. """ if self.has_collection(index_name): - log.debug(f"Index '{index_name}' already exists, skipping creation") + log.debug("Index '%s' already exists, skipping creation", index_name) return try: @@ -70,7 +70,9 @@ class S3VectorClient(VectorDBBase): ] }, ) - log.info(f'Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})') + log.info( + 'Created S3 index: %s (dim=%s, type=%s, metric=%s)', index_name, dimension, data_type, distance_metric + ) except Exception as e: log.error(f"Error creating S3 index '{index_name}': {e}") raise @@ -137,9 +139,9 @@ class S3VectorClient(VectorDBBase): return try: - log.info(f"Deleting collection '{collection_name}'") + log.info("Deleting collection '%s'", collection_name) self.client.delete_index(vectorBucketName=self.bucket_name, indexName=collection_name) - log.info(f"Successfully deleted collection '{collection_name}'") + log.info("Successfully deleted collection '%s'", collection_name) except Exception as e: log.error(f"Error deleting collection '{collection_name}': {e}") raise @@ -156,7 +158,7 @@ class S3VectorClient(VectorDBBase): try: if not self.has_collection(collection_name): - log.info(f"Index '{collection_name}' does not exist. Creating index.") + log.info("Index '%s' does not exist. Creating index.", collection_name) self._create_index( index_name=collection_name, dimension=dimension, @@ -202,9 +204,11 @@ class S3VectorClient(VectorDBBase): indexName=collection_name, vectors=batch, ) - log.info(f"Inserted batch {i // batch_size + 1}: {len(batch)} vectors into index '{collection_name}'.") + log.info( + "Inserted batch %s: %s vectors into index '%s'.", i // batch_size + 1, len(batch), collection_name + ) - log.info(f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'.") + log.info("Completed insertion of %s vectors into index '%s'.", len(vectors), collection_name) except Exception as e: log.error(f'Error inserting vectors: {e}') raise @@ -218,11 +222,11 @@ class S3VectorClient(VectorDBBase): return dimension = len(items[0]['vector']) - log.info(f'Upsert dimension: {dimension}') + log.info('Upsert dimension: %s', dimension) try: if not self.has_collection(collection_name): - log.info(f"Index '{collection_name}' does not exist. Creating index for upsert.") + log.info("Index '%s' does not exist. Creating index for upsert.", collection_name) self._create_index( index_name=collection_name, dimension=dimension, @@ -264,10 +268,14 @@ class S3VectorClient(VectorDBBase): batch = vectors[i : i + batch_size] if i == 0: # Log sample info for first batch only log.info( - f'Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]["key"]}, data_type={type(batch[0]["data"]["float32"])}, data_len={len(batch[0]["data"]["float32"])}' + 'Upserting batch 1: %s vectors. First vector sample: key=%s, data_type=%s, data_len=%s', + len(batch), + batch[0]['key'], + type(batch[0]['data']['float32']), + len(batch[0]['data']['float32']), ) else: - log.info(f'Upserting batch {i // batch_size + 1}: {len(batch)} vectors.') + log.info('Upserting batch %s: %s vectors.', i // batch_size + 1, len(batch)) self.client.put_vectors( vectorBucketName=self.bucket_name, @@ -275,7 +283,7 @@ class S3VectorClient(VectorDBBase): vectors=batch, ) - log.info(f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'.") + log.info("Completed upsert of %s vectors into index '%s'.", len(vectors), collection_name) except Exception as e: log.error(f'Error upserting vectors: {e}') raise @@ -300,7 +308,8 @@ class S3VectorClient(VectorDBBase): return None try: - log.info(f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}") + log.info("Searching collection '%s' with %s query vectors, limit=%s", collection_name, len(vectors), limit) + vector_filter = normalize_filter(filter) # Initialize result lists all_ids = [] @@ -310,20 +319,23 @@ class S3VectorClient(VectorDBBase): # Process each query vector for i, query_vector in enumerate(vectors): - log.debug(f'Processing query vector {i + 1}/{len(vectors)}') + log.debug('Processing query vector %s/%s', i + 1, len(vectors)) # Prepare the query vector in S3 Vector format query_vector_dict = {'float32': [float(x) for x in query_vector]} - # Call S3 Vector query API - response = self.client.query_vectors( - vectorBucketName=self.bucket_name, - indexName=collection_name, - topK=limit, - queryVector=query_vector_dict, - returnMetadata=True, - returnDistance=True, - ) + request_params = { + 'vectorBucketName': self.bucket_name, + 'indexName': collection_name, + 'topK': limit, + 'queryVector': query_vector_dict, + 'returnMetadata': True, + 'returnDistance': True, + } + if vector_filter: + request_params['filter'] = vector_filter + + response = self.client.query_vectors(**request_params) # Process results for this query query_ids = [] @@ -338,6 +350,9 @@ class S3VectorClient(VectorDBBase): vector_metadata = vector.get('metadata', {}) vector_distance = vector.get('distance', 0.0) + if vector_filter and not metadata_matches_filter(vector_metadata, vector_filter): + continue + # Extract document text from metadata document_text = '' if isinstance(vector_metadata, dict): @@ -362,7 +377,7 @@ class S3VectorClient(VectorDBBase): all_metadatas.append(query_metadatas) all_distances.append(query_distances) - log.info(f'Search completed. Found results for {len(all_ids)} queries') + log.info('Search completed. Found results for %s queries', len(all_ids)) # Return SearchResult format return SearchResult( @@ -402,7 +417,7 @@ class S3VectorClient(VectorDBBase): return self.get(collection_name) try: - log.info(f"Querying collection '{collection_name}' with filter: {filter}") + log.info("Querying collection '%s' with filter: %s", collection_name, filter) # For S3 Vector, we need to use list_vectors and then filter results # Since S3 Vector may not support complex server-side filtering, @@ -437,7 +452,7 @@ class S3VectorClient(VectorDBBase): if limit and len(filtered_ids) >= limit: break - log.info(f'Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total') + log.info('Filter applied: %s vectors match out of %s total', len(filtered_ids), len(all_ids)) # Return GetResult format if filtered_ids: @@ -472,7 +487,7 @@ class S3VectorClient(VectorDBBase): return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) try: - log.info(f"Retrieving all vectors from collection '{collection_name}'") + log.info("Retrieving all vectors from collection '%s'", collection_name) # Initialize result lists all_ids = [] @@ -521,7 +536,7 @@ class S3VectorClient(VectorDBBase): ) # Log the actual content for debugging - log.debug(f'Document text preview (first 200 chars): {str(document_text)[:200]}') + log.debug('Document text preview (first 200 chars): %s', str(document_text)[:200]) else: document_text = vector_id @@ -534,7 +549,7 @@ class S3VectorClient(VectorDBBase): if not next_token: break - log.info(f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'") + log.info("Retrieved %s vectors from collection '%s'", len(all_ids), collection_name) # Return in GetResult format # The Open WebUI GetResult expects lists of lists, so we wrap each list @@ -576,17 +591,17 @@ class S3VectorClient(VectorDBBase): try: if ids: # Delete by specific vector IDs/keys - log.info(f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'") + log.info("Deleting %s vectors by IDs from collection '%s'", len(ids), collection_name) self.client.delete_vectors( vectorBucketName=self.bucket_name, indexName=collection_name, keys=ids, ) - log.info(f"Deleted {len(ids)} vectors from index '{collection_name}'") + log.info("Deleted %s vectors from index '%s'", len(ids), collection_name) elif filter: # Handle filter-based deletion - log.info(f"Deleting vectors by filter from collection '{collection_name}': {filter}") + log.info("Deleting vectors by filter from collection '%s': %s", collection_name, filter) # If this is a knowledge collection and we have a file_id filter, # also clean up the corresponding file-specific collection @@ -595,7 +610,8 @@ class S3VectorClient(VectorDBBase): file_collection_name = f'file-{file_id}' if self.has_collection(file_collection_name): log.info( - f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates" + "Found related file-specific collection '%s', deleting it to prevent duplicates", + file_collection_name, ) self.delete_collection(file_collection_name) @@ -604,7 +620,7 @@ class S3VectorClient(VectorDBBase): query_result = self.query(collection_name, filter) if query_result and query_result.ids and query_result.ids[0]: matching_ids = query_result.ids[0] - log.info(f'Found {len(matching_ids)} vectors matching filter, deleting them') + log.info('Found %s vectors matching filter, deleting them', len(matching_ids)) # Delete the matching vectors by ID self.client.delete_vectors( @@ -612,7 +628,7 @@ class S3VectorClient(VectorDBBase): indexName=collection_name, keys=matching_ids, ) - log.info(f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter") + log.info("Deleted %s vectors from index '%s' using filter", len(matching_ids), collection_name) else: log.warning('No vectors found matching the filter criteria') else: @@ -645,11 +661,11 @@ class S3VectorClient(VectorDBBase): try: self.client.delete_index(vectorBucketName=self.bucket_name, indexName=index_name) deleted_count += 1 - log.info(f'Deleted index: {index_name}') + log.info('Deleted index: %s', index_name) except Exception as e: log.error(f"Error deleting index '{index_name}': {e}") - log.info(f'Reset completed: deleted {deleted_count} indexes') + log.info('Reset completed: deleted %s indexes', deleted_count) except Exception as e: log.error(f'Error during reset: {e}') diff --git a/backend/open_webui/retrieval/vector/dbs/valkey.py b/backend/open_webui/retrieval/vector/dbs/valkey.py index 1db10281a4..bf202a22c0 100644 --- a/backend/open_webui/retrieval/vector/dbs/valkey.py +++ b/backend/open_webui/retrieval/vector/dbs/valkey.py @@ -2,7 +2,6 @@ # Requires Valkey core >= 9.0.1 with the valkey-search module >= 1.2.0 loaded. import atexit -import json import logging import re import struct @@ -24,6 +23,7 @@ from open_webui.retrieval.vector.main import ( VectorItem, ) from open_webui.retrieval.vector.utils import process_metadata +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -279,7 +279,7 @@ class ValkeyClient(VectorDBBase): f'{self._format_version(MIN_VALKEY_VERSION)}. valkey-search 1.2.0 requires Valkey core ' '9.0.1 or later. Upgrade your server or use valkey-bundle:9.1.0-rc2+.' ) - log.info(f'Valkey core version: {self._format_version(version) if version else "unknown"}') + log.info('Valkey core version: %s', self._format_version(version) if version else 'unknown') def _check_search_module(self) -> None: try: @@ -331,7 +331,7 @@ class ValkeyClient(VectorDBBase): 'TEXT field type and filter-only FT.SEARCH support required by this backend. ' 'Upgrade to valkey-bundle:9.1.0-rc2+ or load valkey-search 1.2.0+ as a module.' ) - log.info(f'valkey-search version: {self._format_version(search_version) if search_version else "unknown"}') + log.info('valkey-search version: %s', self._format_version(search_version) if search_version else 'unknown') def _index_name(self, collection_name: str) -> str: return f'idx:{self.collection_prefix}:{collection_name}' @@ -385,12 +385,15 @@ class ValkeyClient(VectorDBBase): try: g['glide_ft'].create(self.client, index_name, schema, options) log.info( - f'Created Valkey index {index_name} with dimension={dimension}, ' - f'type={self.index_type}, metric={self.distance_metric}' + 'Created Valkey index %s with dimension=%s, type=%s, metric=%s', + index_name, + dimension, + self.index_type, + self.distance_metric, ) except g['RequestError'] as e: if 'already exists' in str(e).lower(): - log.debug(f'Index {index_name} already exists, skipping creation.') + log.debug('Index %s already exists, skipping creation.', index_name) else: raise @@ -456,9 +459,9 @@ class ValkeyClient(VectorDBBase): index_name = self._index_name(collection_name) try: self._g['glide_ft'].dropindex(self.client, index_name) - log.info(f'Dropped index {index_name}') + log.info('Dropped index %s', index_name) except self._g['RequestError'] as e: - log.debug(f'Could not drop index {index_name}: {e}') + log.debug('Could not drop index %s: %s', index_name, e) self._delete_keys_by_prefix(self._key_prefix(collection_name)) @@ -482,7 +485,7 @@ class ValkeyClient(VectorDBBase): 'id': item['id'], 'vector': _vector_to_bytes(item['vector']), 'text': item['text'], - 'metadata_json': json.dumps(metadata), + 'metadata_json': JSONCodec.dumps(metadata), # `or ''` prevents indexing literal 'None' as a TAG value, which would # poison $ne / equality queries. 'hash': str(metadata.get('hash') or ''), @@ -492,7 +495,7 @@ class ValkeyClient(VectorDBBase): } self.batch_client.hset(self._item_key(collection_name, item['id']), mapping) - log.debug(f'Inserted {len(items)} items into collection {collection_name}') + log.debug('Inserted %s items into collection %s', len(items), collection_name) def upsert(self, collection_name: str, items: list[VectorItem]): self.insert(collection_name, items) @@ -588,8 +591,8 @@ class ValkeyClient(VectorDBBase): ids.append(_decode(fields.get(b'id', b''))) documents.append(_decode(fields.get(b'text', b''))) try: - metadatas.append(json.loads(_decode(fields.get(b'metadata_json', b'{}')))) - except (json.JSONDecodeError, TypeError): + metadatas.append(JSONCodec.loads(_decode(fields.get(b'metadata_json', b'{}')))) + except (ValueError, TypeError): metadatas.append({}) if limit is not None and limit > 0 and len(ids) >= limit: return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) @@ -656,7 +659,7 @@ class ValkeyClient(VectorDBBase): collections.append(name[len(idx_prefix) :]) try: glide_ft.dropindex(self.client, idx) - log.info(f'Dropped index: {name}') + log.info('Dropped index: %s', name) except Exception as e: log.error(f'Error dropping index {name}: {e}') except Exception as e: @@ -664,7 +667,7 @@ class ValkeyClient(VectorDBBase): for collection in collections: self._delete_keys_by_prefix(self._key_prefix(collection)) - log.info(f'Valkey vector store reset complete (prefix: {self.collection_prefix})') + log.info('Valkey vector store reset complete (prefix: %s)', self.collection_prefix) def _delete_keys_by_prefix(self, prefix: str) -> None: cursor = '0' @@ -734,8 +737,8 @@ class ValkeyClient(VectorDBBase): ids.append(_decode(fields.get(b'id', b''))) documents.append(_decode(fields.get(b'text', b''))) try: - metadatas.append(json.loads(_decode(fields.get(b'metadata_json', b'{}')))) - except (json.JSONDecodeError, TypeError): + metadatas.append(JSONCodec.loads(_decode(fields.get(b'metadata_json', b'{}')))) + except (ValueError, TypeError): metadatas.append({}) if include_score: diff --git a/backend/open_webui/retrieval/vector/dbs/weaviate.py b/backend/open_webui/retrieval/vector/dbs/weaviate.py index a896d8ed5e..6d398d4ced 100644 --- a/backend/open_webui/retrieval/vector/dbs/weaviate.py +++ b/backend/open_webui/retrieval/vector/dbs/weaviate.py @@ -23,7 +23,7 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) -from open_webui.retrieval.vector.utils import process_metadata +from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata def _convert_uuids_to_strings(obj: Any) -> Any: @@ -54,6 +54,20 @@ def _convert_uuids_to_strings(obj: Any) -> Any: return obj +def _metadata_filter(filter: Optional[dict]) -> Any: + clauses = [] + for key, op, value in iter_filter_conditions(filter): + if op == '$in': + clauses.append( + weaviate.classes.query.Filter.any_of( + [weaviate.classes.query.Filter.by_property(name=key).equal(item) for item in value] + ) + ) + else: + clauses.append(weaviate.classes.query.Filter.by_property(name=key).equal(value)) + return weaviate.classes.query.Filter.all_of(clauses) if len(clauses) > 1 else (clauses[0] if clauses else None) + + class WeaviateClient(VectorDBBase): def __init__(self): self.url = WEAVIATE_HTTP_HOST @@ -168,6 +182,7 @@ class WeaviateClient(VectorDBBase): return None collection = self.client.collections.get(sane_collection_name) + weaviate_filter = _metadata_filter(filter) result_ids, result_documents, result_metadatas, result_distances = ( [], @@ -181,6 +196,7 @@ class WeaviateClient(VectorDBBase): response = collection.query.near_vector( near_vector=vector_embedding, limit=limit, + filters=weaviate_filter, return_metadata=weaviate.classes.query.MetadataQuery(distance=True), ) diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py index 31b5b0748c..19bba9b1ef 100644 --- a/backend/open_webui/retrieval/vector/utils.py +++ b/backend/open_webui/retrieval/vector/utils.py @@ -1,16 +1,38 @@ import datetime as dt from typing import Any +from open_webui.env import RAG_METADATA_MAX_VALUE_CHARS from open_webui.retrieval.vector.main import SearchResult from open_webui.utils.misc import sanitize_text_for_db -KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures'] +KEYS_TO_EXCLUDE = [ + 'content', + 'pages', + 'tables', + 'paragraphs', + 'sections', + 'figures', + 'documents', + 'keyValuePairs', + 'styles', + 'languages', +] def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: # Removes large/redundant fields from metadata dict. - metadata = {key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE} - return metadata + result = {} + for key, value in metadata.items(): + if key in KEYS_TO_EXCLUDE: + continue + if RAG_METADATA_MAX_VALUE_CHARS is not None and isinstance(value, (list, dict)): + try: + if len(str(value)) > RAG_METADATA_MAX_VALUE_CHARS: + continue + except (MemoryError, RecursionError, ValueError): + continue + result[key] = value + return result def process_metadata( @@ -25,6 +47,12 @@ def process_metadata( continue if value is None: continue + if RAG_METADATA_MAX_VALUE_CHARS is not None and isinstance(value, (list, dict)): + try: + if len(str(value)) > RAG_METADATA_MAX_VALUE_CHARS: + continue + except (MemoryError, RecursionError, ValueError): + continue # Convert non-serializable fields to strings if isinstance(value, (dt.datetime, list, dict)): result[key] = sanitize_text_for_db(str(value)) @@ -33,6 +61,33 @@ def process_metadata( return result +def iter_filter_conditions(filter: dict[str, Any] | None): + for key, value in (filter or {}).items(): + if isinstance(value, dict): + if set(value) != {'$in'}: + raise ValueError(f"Unsupported metadata filter for '{key}': {value}") + yield key, '$in', list(value['$in']) + else: + yield key, '$eq', value + + +def normalize_filter(filter: dict[str, Any] | None) -> dict[str, Any]: + return {key: {'$in': value} if op == '$in' else value for key, op, value in iter_filter_conditions(filter)} + + +def metadata_matches_filter(metadata: dict[str, Any], filter: dict[str, Any] | None) -> bool: + if not isinstance(metadata, dict): + return False + for key, op, value in iter_filter_conditions(filter): + actual = metadata.get(key) + if op == '$in': + if actual not in value: + return False + elif actual != value: + return False + return True + + def merge_hybrid_search_results( vector_result: SearchResult | None, fts_results: list[dict[str, Any]], diff --git a/backend/open_webui/retrieval/web/bocha.py b/backend/open_webui/retrieval/web/bocha.py index cb94646310..c60a891750 100644 --- a/backend/open_webui/retrieval/web/bocha.py +++ b/backend/open_webui/retrieval/web/bocha.py @@ -1,9 +1,9 @@ -import json import logging from typing import Optional import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -41,7 +41,7 @@ def search_bocha(api_key: str, query: str, count: int, filter_list: Optional[lis url = 'https://api.bochaai.com/v1/web-search?utm_source=ollama' headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'} - payload = json.dumps({'query': query, 'summary': True, 'freshness': 'noLimit', 'count': count}) + payload = JSONCodec.dumps({'query': query, 'summary': True, 'freshness': 'noLimit', 'count': count}) response = requests.post(url, headers=headers, data=payload, timeout=5) response.raise_for_status() diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index 27d56f6934..2d57b071cd 100644 --- a/backend/open_webui/retrieval/web/duckduckgo.py +++ b/backend/open_webui/retrieval/web/duckduckgo.py @@ -4,7 +4,6 @@ import logging import urllib.request from ddgs import DDGS -from ddgs.exceptions import RatelimitException from open_webui.retrieval.web.main import SearchResult, get_filtered_results log = logging.getLogger(__name__) @@ -31,21 +30,11 @@ def search_duckduckgo( # Resolve via stdlib getproxies() — same pattern as the other loaders. env_proxies = urllib.request.getproxies() proxy = env_proxies.get('https') or env_proxies.get('http') - search_results = [] with DDGS(proxy=proxy) as ddgs: if concurrent_requests: ddgs.threads = concurrent_requests - # Use the ddgs.text() method to perform the search - try: - kwargs = {'safesearch': 'moderate', 'max_results': count} - if backend and backend != 'auto': - kwargs['backend'] = backend - results = ddgs.text(query, **kwargs) - search_results = results if results is not None else [] - except RatelimitException as e: - log.error(f'RatelimitException: {e}') - search_results = [] + search_results = ddgs.text(query, safesearch='moderate', max_results=count, backend=backend or 'auto') if filter_list: search_results = get_filtered_results(search_results, filter_list) diff --git a/backend/open_webui/retrieval/web/exa.py b/backend/open_webui/retrieval/web/exa.py index 860917854e..068cc380b9 100644 --- a/backend/open_webui/retrieval/web/exa.py +++ b/backend/open_webui/retrieval/web/exa.py @@ -31,7 +31,7 @@ def search_exa( count (int): Number of results to return filter_list (Optional[list[str]]): List of domains to filter results by """ - log.info(f'Searching with Exa for query: {query}') + log.info('Searching with Exa for query: %s', query) headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'} @@ -58,7 +58,7 @@ def search_exa( ) ) - log.info(f'Found {len(results)} results') + log.info('Found %s results', len(results)) return [ SearchResult( link=result.url, diff --git a/backend/open_webui/retrieval/web/external.py b/backend/open_webui/retrieval/web/external.py index 4db37cb645..983f9d4d34 100644 --- a/backend/open_webui/retrieval/web/external.py +++ b/backend/open_webui/retrieval/web/external.py @@ -21,6 +21,9 @@ def search_external( ) -> List[SearchResult]: try: headers = { + # LICENSE covers this Open WebUI user-agent identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot', 'Authorization': f'Bearer {external_api_key}', } @@ -50,7 +53,7 @@ def search_external( ) for result in results[:count] ] - log.info(f'External search results: {results}') + log.info('External search results: %s', results) return results except Exception as e: log.error(f'Error in External search: {e}') diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py index 04e03aff55..0febae8403 100644 --- a/backend/open_webui/retrieval/web/firecrawl.py +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -225,7 +225,7 @@ def search_firecrawl( ) ) - log.info(f'FireCrawl search results: {search_results}') + log.info('FireCrawl search results: %s', search_results) return search_results except Exception as e: log.error(f'Error in FireCrawl search: {e}') diff --git a/backend/open_webui/retrieval/web/main.py b/backend/open_webui/retrieval/web/main.py index d8127807cf..be297c11c5 100644 --- a/backend/open_webui/retrieval/web/main.py +++ b/backend/open_webui/retrieval/web/main.py @@ -1,11 +1,10 @@ from __future__ import annotations -import ipaddress from urllib.parse import urlparse import validators from open_webui.retrieval.web.utils import resolve_hostname -from open_webui.utils.misc import get_allow_block_lists, is_host_allowed +from open_webui.utils.misc import as_network, get_allow_block_lists, is_host_allowed from pydantic import BaseModel @@ -14,14 +13,8 @@ def get_filtered_results(results, filter_list): return results allow_list, block_list = get_allow_block_lists(filter_list) - resolve_ips = False - for entry in allow_list + block_list: - try: - ipaddress.ip_address(entry) - except ValueError: - continue - resolve_ips = True - break + # Only worth a lookup when an entry names an address, since a hostname entry matches by name. + resolve_ips = any(as_network(entry) is not None for entry in allow_list + block_list) filtered_results = [] diff --git a/backend/open_webui/retrieval/web/microsoft_web_iq.py b/backend/open_webui/retrieval/web/microsoft_web_iq.py index c4188b684c..9d0df53e64 100644 --- a/backend/open_webui/retrieval/web/microsoft_web_iq.py +++ b/backend/open_webui/retrieval/web/microsoft_web_iq.py @@ -9,8 +9,6 @@ from open_webui.utils.headers import include_user_info_headers log = logging.getLogger(__name__) -DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL = 'https://api.microsoft.ai/v3' - def search_microsoft_web_iq( api_base_url: str, @@ -22,7 +20,7 @@ def search_microsoft_web_iq( user=None, ) -> list[SearchResult]: try: - api_base_url = (api_base_url or DEFAULT_MICROSOFT_WEB_IQ_API_BASE_URL).rstrip('/') + api_base_url = api_base_url.rstrip('/') headers = { 'host': urlparse(api_base_url).netloc or 'api.microsoft.ai', 'x-apikey': api_key, diff --git a/backend/open_webui/retrieval/web/ollama.py b/backend/open_webui/retrieval/web/ollama.py index 7ed19b91b1..8535edf172 100644 --- a/backend/open_webui/retrieval/web/ollama.py +++ b/backend/open_webui/retrieval/web/ollama.py @@ -23,7 +23,7 @@ def search_ollama_cloud( count (int): Number of results to return filter_list (Optional[list[str]]): List of domains to filter results by """ - log.info(f'Searching with Ollama for query: {query}') + log.info('Searching with Ollama for query: %s', query) headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'} payload = {'query': query, 'max_results': count} @@ -34,7 +34,7 @@ def search_ollama_cloud( data = response.json() results = data.get('results', []) - log.info(f'Found {len(results)} results') + log.info('Found %s results', len(results)) if filter_list: results = get_filtered_results(results, filter_list) diff --git a/backend/open_webui/retrieval/web/searchapi.py b/backend/open_webui/retrieval/web/searchapi.py index 855269ef02..bd4c292c18 100644 --- a/backend/open_webui/retrieval/web/searchapi.py +++ b/backend/open_webui/retrieval/web/searchapi.py @@ -31,7 +31,7 @@ def search_searchapi( response = requests.request('GET', url) json_response = response.json() - log.info(f'results from searchapi search: {json_response}') + log.info('results from searchapi search: %s', json_response) results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0)) if filter_list: diff --git a/backend/open_webui/retrieval/web/searxng.py b/backend/open_webui/retrieval/web/searxng.py index 6b1c03cdd3..9de56b28de 100644 --- a/backend/open_webui/retrieval/web/searxng.py +++ b/backend/open_webui/retrieval/web/searxng.py @@ -12,6 +12,9 @@ log = logging.getLogger(__name__) # SearXNG request headers — identifies the bot to instance operators. _SEARXNG_HEADERS = { + # LICENSE covers this Open WebUI user-agent identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot', 'Accept': 'text/html', 'Accept-Encoding': 'gzip, deflate', diff --git a/backend/open_webui/retrieval/web/serpapi.py b/backend/open_webui/retrieval/web/serpapi.py index 602f60d7a7..2e5b958937 100644 --- a/backend/open_webui/retrieval/web/serpapi.py +++ b/backend/open_webui/retrieval/web/serpapi.py @@ -31,7 +31,7 @@ def search_serpapi( response = requests.request('GET', url) json_response = response.json() - log.info(f'results from serpapi search: {json_response}') + log.info('results from serpapi search: %s', json_response) results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0)) if filter_list: diff --git a/backend/open_webui/retrieval/web/serper.py b/backend/open_webui/retrieval/web/serper.py index 1304529404..048e076dac 100644 --- a/backend/open_webui/retrieval/web/serper.py +++ b/backend/open_webui/retrieval/web/serper.py @@ -1,9 +1,9 @@ from __future__ import annotations -import json import logging from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.session_pool import get_session log = logging.getLogger(__name__) @@ -23,7 +23,7 @@ async def search_serper( headers = {'X-API-KEY': api_key, 'Content-Type': 'application/json'} session = await get_session() - async with session.post(url, headers=headers, data=json.dumps({'q': query})) as response: + async with session.post(url, headers=headers, data=JSONCodec.dumps({'q': query})) as response: response.raise_for_status() payload = await response.json() diff --git a/backend/open_webui/retrieval/web/serply.py b/backend/open_webui/retrieval/web/serply.py index c4909c14e0..1daac461df 100644 --- a/backend/open_webui/retrieval/web/serply.py +++ b/backend/open_webui/retrieval/web/serply.py @@ -51,7 +51,7 @@ def search_serply( response.raise_for_status() json_response = response.json() - log.info(f'results from serply search: {json_response}') + log.info('results from serply search: %s', json_response) results = sorted(json_response.get('results', []), key=lambda x: x.get('realPosition', 0)) if filter_list: diff --git a/backend/open_webui/retrieval/web/sougou.py b/backend/open_webui/retrieval/web/sougou.py index 3d12e2a57b..8e3ddf4e5a 100644 --- a/backend/open_webui/retrieval/web/sougou.py +++ b/backend/open_webui/retrieval/web/sougou.py @@ -1,8 +1,8 @@ -import json import logging from typing import List, Optional from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -28,10 +28,11 @@ def search_sougou( http_profile.endpoint = 'tms.tencentcloudapi.com' client_profile = ClientProfile() client_profile.http_profile = http_profile - params = json.dumps({'Query': query, 'Cnt': 20}) + params = JSONCodec.dumps({'Query': query, 'Cnt': 20}) common_client = CommonClient('tms', '2020-12-29', cred, '', profile=client_profile) results = [ - json.loads(page) for page in common_client.call_json('SearchPro', json.loads(params))['Response']['Pages'] + JSONCodec.loads(page) + for page in common_client.call_json('SearchPro', JSONCodec.loads(params))['Response']['Pages'] ] sorted_results = sorted(results, key=lambda x: x.get('scour', 0.0), reverse=True) if filter_list: diff --git a/backend/open_webui/retrieval/web/tavily.py b/backend/open_webui/retrieval/web/tavily.py index 419bebb05e..73cf73152e 100644 --- a/backend/open_webui/retrieval/web/tavily.py +++ b/backend/open_webui/retrieval/web/tavily.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import requests +from open_webui.env import TAVILY_API_BASE_URL from open_webui.retrieval.web.main import SearchResult, get_filtered_results log = logging.getLogger(__name__) @@ -25,7 +26,7 @@ def search_tavily( Returns: A list of SearchResult objects. """ - url = 'https://api.tavily.com/search' + url = f'{TAVILY_API_BASE_URL}/search' headers = { 'Content-Type': 'application/json', 'Authorization': f'Bearer {api_key}', diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 8c13689fab..a6099afe1d 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -1,4 +1,5 @@ import asyncio +import http.cookiejar import ipaddress import logging import socket @@ -11,17 +12,19 @@ from typing import ( Any, AsyncIterator, Dict, + Iterable, Iterator, List, Literal, Optional, Sequence, + Tuple, Union, ) import aiohttp -import aiohttp.resolver import certifi +import requests import urllib3.connection import urllib3.connectionpool import validators @@ -52,6 +55,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.env import ( AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_SSL_CERT_FILE, AIOHTTP_CLIENT_TIMEOUT, USER_AGENT, ) @@ -59,7 +63,7 @@ from open_webui.retrieval.loaders.external_web import ExternalWebLoader from open_webui.retrieval.loaders.microsoft_web_iq import MicrosoftWebIQLoader from open_webui.retrieval.loaders.tavily import TavilyLoader from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url -from open_webui.utils.misc import is_host_allowed +from open_webui.utils.misc import is_host_allowed, is_host_blocked log = logging.getLogger(__name__) @@ -75,12 +79,10 @@ def resolve_hostname(hostname): return ipv4_addresses, ipv6_addresses -def _is_global_addr(ip: str) -> bool: - addr = ipaddress.ip_address(ip) - if not addr.is_global: - return False +def _embedded_ipv4(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> list[ipaddress.IPv4Address]: + """The IPv4 addresses an IPv6 address carries: mapped, compatible, 6to4, teredo and NAT64.""" if not isinstance(addr, ipaddress.IPv6Address): - return True + return [] embedded = [] if addr.ipv4_mapped: @@ -91,16 +93,37 @@ def _is_global_addr(ip: str) -> bool: embedded.extend(addr.teredo) b = addr.packed - if b[:12] == b'\x00' * 12: - embedded.append(ipaddress.IPv4Address(b[12:])) - elif b[:12] == b'\x00\x64\xff\x9b' + b'\x00' * 8: + # Prefixes that put the address in the last four bytes: v4-compatible and NAT64 /96. + if b[:12] in (b'\x00' * 12, b'\x00\x64\xff\x9b' + b'\x00' * 8): embedded.append(ipaddress.IPv4Address(b[12:])) elif b[:6] == b'\x00\x64\xff\x9b\x00\x01': - if b[8] != 0: - return False embedded.append(ipaddress.IPv4Address(bytes((b[6], b[7], b[9], b[10])))) - return all(ip.is_global for ip in embedded) + return embedded + + +def _assert_host_allowed(host: str | None) -> None: + if WEB_FETCH_FILTER_LIST and not is_host_allowed(host, WEB_FETCH_FILTER_LIST): + log.warning(f'Blocked by filter list: {host}') + raise ValueError(ERROR_MESSAGES.INVALID_URL) + + +def _assert_addresses_allowed(addresses: Sequence[str]) -> None: + # An IPv6 address can carry a blocked IPv4 address inside it, so judge both spellings. + parsed = [ipaddress.ip_address(address) for address in addresses] + candidates = [*parsed, *(ipv4 for address in parsed for ipv4 in _embedded_ipv4(address))] + + # Block entries only: an allow entry names a host, so judging a resolved address against one + # would reject every allow-listed host. + if is_host_blocked([str(address) for address in candidates], WEB_FETCH_FILTER_LIST): + log.warning(f'Blocked by filter list: {", ".join(str(address) for address in candidates)}') + raise ValueError(ERROR_MESSAGES.INVALID_URL) + + if not ENABLE_LOCAL_WEB_FETCH: + for address in candidates: + if not address.is_global: + log.warning(f'Blocked non-global address: {address}') + raise ValueError(ERROR_MESSAGES.INVALID_URL) def validate_url(url: Union[str, Sequence[str]]): @@ -123,24 +146,22 @@ def validate_url(url: Union[str, Sequence[str]]): log.warning(f'Blocked non-HTTP(S) protocol: {parsed_url.scheme} in URL: {url}') raise ValueError(ERROR_MESSAGES.INVALID_URL) - # Blocklist check using unified filtering logic - if WEB_FETCH_FILTER_LIST: - # Match on the parsed hostname, not the full URL: a path component would - # otherwise let any URL slip past a hostname-based block/allow entry. - if not is_host_allowed(parsed_url.hostname, WEB_FETCH_FILTER_LIST): - log.warning(f'URL blocked by filter list: {url}') - raise ValueError(ERROR_MESSAGES.INVALID_URL) + # Match on the parsed hostname, not the full URL: a path component would + # otherwise let any URL slip past a hostname-based block/allow entry. + _assert_host_allowed(parsed_url.hostname) - if not ENABLE_LOCAL_WEB_FETCH: - # Local web fetch is disabled, filter out URLs that resolve to non-global IP addresses. - parsed_url = urllib.parse.urlparse(url) - # Get IPv4 and IPv6 addresses + try: ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname) - # Check if any of the resolved addresses are private - # DNS rebinding is mitigated at the connection layer; see _SSRFSafeResolver / _SSRFSafeAdapter - for ip in ipv4_addresses + ipv6_addresses: - if not _is_global_addr(ip): - raise ValueError(ERROR_MESSAGES.INVALID_URL) + except (socket.gaierror, UnicodeError) as e: + # With local fetch on, a proxied deployment can carry names only the proxy resolves. + if not ENABLE_LOCAL_WEB_FETCH: + log.warning(f'Could not resolve host {parsed_url.hostname}: {e}') + raise ValueError(ERROR_MESSAGES.INVALID_URL) from None + ipv4_addresses, ipv6_addresses = [], [] + + # A hostname match alone lets a DNS record point at a blocked address. + # DNS rebinding is mitigated at the connection layer; see _SSRFSafeConnector / _SSRFSafeAdapter + _assert_addresses_allowed(ipv4_addresses + ipv6_addresses) return True elif isinstance(url, Sequence): return all(validate_url(u) for u in url) @@ -155,13 +176,13 @@ def safe_validate_urls(url: Sequence[str]) -> Sequence[str]: if validate_url(u): valid_urls.append(u) except Exception as e: - log.debug(f'Invalid URL {u}: {str(e)}') + log.debug('Invalid URL %s: %s', u, e) continue return valid_urls def _ssrf_safe_new_conn(self): - """Resolve DNS, validate all IPs are global, connect to validated IP. + """Resolve DNS, screen every resolved address, connect to one of them. Replaces urllib3's _new_conn so the DNS lookup that feeds the actual TCP connect is the same one we validate — no second resolution, no rebinding @@ -172,10 +193,7 @@ def _ssrf_safe_new_conn(self): infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM) if not infos: raise OSError(f'getaddrinfo for {host!r} returned empty list') - if not ENABLE_LOCAL_WEB_FETCH: - for _, _, _, _, sa in infos: - if not _is_global_addr(sa[0]): - raise ValueError(ERROR_MESSAGES.INVALID_URL) + _assert_addresses_allowed([sa[0] for _, _, _, _, sa in infos]) err = None for fam, typ, proto, _, sa in infos: sock = None @@ -218,7 +236,7 @@ class _SafeHTTPSPool(urllib3.connectionpool.HTTPSConnectionPool): class _SSRFSafeAdapter(HTTPAdapter): - """requests transport adapter that validates resolved IPs at connect time.""" + """requests adapter that rejects filter-listed request targets and non-global IPs at connect time.""" def init_poolmanager(self, *args, **kwargs): super().init_poolmanager(*args, **kwargs) @@ -227,32 +245,82 @@ class _SSRFSafeAdapter(HTTPAdapter): 'https': _SafeHTTPSPool, } + def send(self, request, *args, **kwargs): + # Per request, not per connection: the connection layer sees the proxy. + _assert_host_allowed(urllib.parse.urlparse(request.url).hostname) + return super().send(request, *args, **kwargs) -class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver): - """aiohttp resolver that rejects non-global IPs unless local fetch is on.""" - async def resolve(self, host, port=0, family=socket.AF_INET): - results = await super().resolve(host, port, family) - if not ENABLE_LOCAL_WEB_FETCH: - for entry in results: - if not _is_global_addr(entry['host']): - raise ValueError(ERROR_MESSAGES.INVALID_URL) +class _SSRFSafeConnector(aiohttp.TCPConnector): + """Rejects filter-listed request targets, and non-global IPs on each new connection.""" + + async def connect(self, req, traces, timeout): + # Per request, not per connection: _resolve_host sees the proxy and pooled reuse skips it. + _assert_host_allowed(req.url.host) + return await super().connect(req, traces, timeout) + + async def _resolve_host(self, host, port, traces=None): + # aiohttp answers IP-literal hosts itself without consulting a resolver. + results = await super()._resolve_host(host, port, traces=traces) + _assert_addresses_allowed([entry['host'] for entry in results]) return results -def get_ssrf_safe_session() -> aiohttp.ClientSession: - """A one-off aiohttp session that re-validates the connect-time IP via _SSRFSafeResolver, +def get_ssrf_safe_session(trust_env: bool = True, store_cookies: bool = True) -> aiohttp.ClientSession: + """A one-off aiohttp session that re-validates every connection via _SSRFSafeConnector, defeating DNS rebinding. Use for validate_url-gated fetches of user-supplied URLs that must not use the shared (rebinding-vulnerable) pool. Use as a context manager so it is closed: ``async with get_ssrf_safe_session() as session: ...``. + + trust_env also enables environment proxies, and proxied traffic bypasses the connect-time + IP check, because the proxy resolves the hostname instead. """ return aiohttp.ClientSession( - connector=aiohttp.TCPConnector(resolver=_SSRFSafeResolver()), + connector=_SSRFSafeConnector(), timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), - trust_env=True, + trust_env=trust_env, + cookie_jar=None if store_cookies else aiohttp.DummyCookieJar(), ) +def get_ssrf_safe_requests_session(trust_env: bool = True, store_cookies: bool = True) -> requests.Session: + """The requests counterpart of get_ssrf_safe_session, with the same proxy caveat.""" + session = requests.Session() + session.trust_env = trust_env + if not store_cookies: + session.cookies.set_policy(http.cookiejar.DefaultCookiePolicy(allowed_domains=[])) + session.mount('http://', _SSRFSafeAdapter()) + session.mount('https://', _SSRFSafeAdapter()) + return session + + +# accept-encoding goes because the client must advertise only codecs it can decode, the rest +# because the client derives them from the URL and body it is actually given. content-encoding +# stays: the browser's body is forwarded byte for byte, so its own labelling still applies. +_DROPPED_REQUEST_HEADERS = {'accept-encoding', 'connection', 'content-length', 'host', 'transfer-encoding'} + +# The clients hand us a decoded body, so the sender's framing no longer describes it. +_DROPPED_RESPONSE_HEADERS = {'connection', 'content-encoding', 'content-length', 'transfer-encoding'} + + +def _forwardable_request_headers(headers: Dict[str, str]) -> Dict[str, str]: + return {name: value for name, value in headers.items() if name.lower() not in _DROPPED_REQUEST_HEADERS} + + +def _fulfillable_response_headers(header_pairs: Iterable[Tuple[str, str]]) -> Dict[str, str]: + """Collapse repeated headers the way route.fulfill expects: set-cookie by newline, rest by comma. + + Takes pairs rather than a mapping because reading either client's headers as a mapping loses + duplicate Set-Cookie values, leaving one malformed cookie or one of the two. + """ + collected: Dict[str, List[str]] = {} + for name, value in header_pairs: + name = name.lower() # grouping by the sender's case would split a repeated header + if name not in _DROPPED_RESPONSE_HEADERS: + collected.setdefault(name, []).append(value) + return {name: ('\n' if name == 'set-cookie' else ', ').join(values) for name, values in collected.items()} + + def extract_metadata(soup, url): metadata = {'source': url} if title := soup.find('title'): @@ -404,7 +472,6 @@ class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): def __init__( self, web_paths: Union[str, List[str]], - api_base_url: str, api_key: str, extract_depth: Literal['basic', 'advanced'] = 'basic', continue_on_failure: bool = True, @@ -438,7 +505,6 @@ class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): # Store parameters for creating TavilyLoader instances self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths] - self.api_base_url = api_base_url self.api_key = api_key self.extract_depth = extract_depth self.continue_on_failure = continue_on_failure @@ -519,6 +585,7 @@ class SafeMicrosoftWebIQLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): self, web_paths: Union[str, List[str]], api_key: str, + api_base_url: str = MICROSOFT_WEB_IQ_API_BASE_URL, language: str = 'en', verify_ssl: bool = True, trust_env: bool = False, @@ -528,6 +595,7 @@ class SafeMicrosoftWebIQLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): ): self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths] self.api_key = api_key + self.api_base_url = api_base_url self.language = language self.verify_ssl = verify_ssl self.trust_env = trust_env @@ -585,7 +653,9 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing requests_per_second (Optional[float]): Number of requests per second to limit to. continue_on_failure (bool): If True, continue loading other URLs on failure. headless (bool): If True, the browser will run in headless mode. - proxy (dict): Proxy override settings for the Playwright session. + proxy (dict): Proxy override settings for the Playwright session. Page requests are + issued outside the browser, so they follow the environment proxy via trust_env + rather than this setting. playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection. playwright_timeout (Optional[int]): Maximum operation time in milliseconds. """ @@ -630,14 +700,49 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing self.trust_env = trust_env self.playwright_timeout = playwright_timeout - def _intercept_navigation_sync(self, route, request=None): - req = request or route.request + def _request_timeout(self) -> float: + # per-hop budget, since page.goto's timeout cannot reach into our own fetch and 0 disables + # it. aiohttp treats it as a total where requests only caps each read, so sync runs looser. + return (self.playwright_timeout or 30000) / 1000 + + def _requests_verify(self) -> Union[bool, str]: + """requests takes a CA path where aiohttp takes the parsed SSLContext. + + A bundle named directly in AIOHTTP_CLIENT_SESSION_SSL reaches us already parsed and + cannot be expressed here, so that form falls back to the global bundle or certifi. + """ + if not self.verify_ssl or AIOHTTP_CLIENT_SESSION_SSL is False: + return False + if AIOHTTP_CLIENT_SESSION_SSL is True: + return True # no usable global CA bundle, so both clients land on certifi + return AIOHTTP_CLIENT_SSL_CERT_FILE or True + + def _intercept_navigation_sync(self, route, session): + req = route.request + + hop_cookies: List[Tuple[str, str]] = [] try: - validate_url(req.url) - resp = route.fetch(max_redirects=0) + headers = _forwardable_request_headers(req.all_headers()) + post_data = req.post_data_buffer + verify, timeout = self._requests_verify(), self._request_timeout() - if 300 <= resp.status < 400: + # The browser would resolve the hostname again, after the check; fetch it ourselves. + def fetch(url): + validate_url(url) + return session.request( + req.method, + url, + headers=headers, + data=post_data, + allow_redirects=False, + verify=verify, + timeout=timeout, + ) + + resp = fetch(req.url) + + if 300 <= resp.status_code < 400: for _ in range(20): if not AIOHTTP_CLIENT_ALLOW_REDIRECTS: route.abort() @@ -647,26 +752,50 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing if not location: break - url = urllib.parse.urljoin(resp.url, location) - validate_url(url) - resp = route.fetch(url=url, max_redirects=0) - if not 300 <= resp.status < 400: + # only the last hop is fulfilled, so carry each hop's cookies to the browser + hop_cookies += [('set-cookie', v) for v in resp.raw.headers.getlist('set-cookie')] + resp = fetch(urllib.parse.urljoin(resp.url, location)) + if not 300 <= resp.status_code < 400: break else: route.abort() return - except Exception: + except Exception as e: + log.debug('Playwright loader could not fetch %s: %s', req.url, e) route.abort() return - route.fulfill(response=resp) + route.fulfill( + status=resp.status_code, + headers=_fulfillable_response_headers(hop_cookies + list(resp.raw.headers.items())), + body=resp.content, + ) - async def _intercept_navigation(self, route, request=None): - req = request or route.request + async def _intercept_navigation(self, route, session): + req = route.request + + hop_cookies: List[Tuple[str, str]] = [] try: - await run_in_threadpool(validate_url, req.url) - resp = await route.fetch(max_redirects=0) + headers = _forwardable_request_headers(await req.all_headers()) + post_data = req.post_data_buffer + + # The browser would resolve the hostname again, after the check; fetch it ourselves. + async def fetch(url): + await run_in_threadpool(validate_url, url) + response = await session.request( + req.method, + url, + headers=headers, + data=post_data, + allow_redirects=False, + ssl=AIOHTTP_CLIENT_SESSION_SSL if self.verify_ssl else False, + timeout=aiohttp.ClientTimeout(total=self._request_timeout()), + ) + # aiohttp only returns the connection to the pool once the body is buffered + return response, await response.read() + + resp, body = await fetch(req.url) if 300 <= resp.status < 400: for _ in range(20): @@ -678,19 +807,24 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing if not location: break - url = urllib.parse.urljoin(resp.url, location) - await run_in_threadpool(validate_url, url) - resp = await route.fetch(url=url, max_redirects=0) + # only the last hop is fulfilled, so carry each hop's cookies to the browser + hop_cookies += [('set-cookie', v) for v in resp.headers.getall('Set-Cookie', [])] + resp, body = await fetch(urllib.parse.urljoin(str(resp.url), location)) if not 300 <= resp.status < 400: break else: await route.abort() return - except Exception: + except Exception as e: + log.debug('Playwright loader could not fetch %s: %s', req.url, e) await route.abort() return - await route.fulfill(response=resp) + await route.fulfill( + status=resp.status, + headers=_fulfillable_response_headers(hop_cookies + list(resp.headers.items())), + body=body, + ) def lazy_load(self) -> Iterator[Document]: """Safely load URLs synchronously with support for remote browser.""" @@ -707,8 +841,12 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing for url in self.urls: try: self._safe_process_url_sync(url) - with browser.new_page(service_workers='block') as page: - page.route('**/*', self._intercept_navigation_sync) + # opened before the page so it outlives any route still in flight at teardown + with ( + get_ssrf_safe_requests_session(self.trust_env, store_cookies=False) as session, + browser.new_page(service_workers='block') as page, + ): + page.route('**/*', lambda route: self._intercept_navigation_sync(route, session)) page.route_web_socket('**/*', lambda ws_route: ws_route.close()) response = page.goto(url, timeout=self.playwright_timeout) if response is None: @@ -738,8 +876,12 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing for url in self.urls: try: await self._safe_process_url(url) - async with await browser.new_page(service_workers='block') as page: - await page.route('**/*', self._intercept_navigation) + # opened before the page so it outlives any route still in flight at teardown + async with ( + get_ssrf_safe_session(self.trust_env, store_cookies=False) as session, + await browser.new_page(service_workers='block') as page, + ): + await page.route('**/*', lambda route: self._intercept_navigation(route, session)) await page.route_web_socket('**/*', lambda ws_route: ws_route.close()) response = await page.goto(url, timeout=self.playwright_timeout) if response is None: @@ -793,7 +935,7 @@ class SafeWebBaseLoader(WebBaseLoader): self.session.mount('https://', _SSRFSafeAdapter()) async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str: - connector = aiohttp.TCPConnector(resolver=_SSRFSafeResolver()) + connector = _SSRFSafeConnector() async with aiohttp.ClientSession(trust_env=self.trust_env, connector=connector) as session: for i in range(retries): try: diff --git a/backend/open_webui/retrieval/web/yacy.py b/backend/open_webui/retrieval/web/yacy.py index 969acb7398..e7f65b5f4a 100644 --- a/backend/open_webui/retrieval/web/yacy.py +++ b/backend/open_webui/retrieval/web/yacy.py @@ -53,12 +53,15 @@ def search_yacy( # Strip all query parameters from the URL query_url = query_url.rstrip('/') + '/yacysearch.json' - log.debug(f'searching {query_url}') + log.debug('searching %s', query_url) response = requests.get( query_url, auth=yacy_auth, headers={ + # LICENSE covers this Open WebUI user-agent identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot', 'Accept': 'text/html', 'Accept-Encoding': 'gzip, deflate', diff --git a/backend/open_webui/retrieval/web/yandex.py b/backend/open_webui/retrieval/web/yandex.py index d338db11a3..0e6306987f 100644 --- a/backend/open_webui/retrieval/web/yandex.py +++ b/backend/open_webui/retrieval/web/yandex.py @@ -1,6 +1,5 @@ import base64 import io -import json import logging import os from typing import List, Optional @@ -12,6 +11,7 @@ from fastapi import Request from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -41,6 +41,9 @@ def search_yandex( ) -> List[SearchResult]: try: headers = { + # LICENSE covers this Open WebUI user-agent identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot', 'Authorization': f'Api-Key {yandex_search_api_key}', } @@ -52,7 +55,7 @@ def search_yandex( if chat_id: headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id) - payload = {} if yandex_search_config == '' else json.loads(yandex_search_config) + payload = {} if yandex_search_config == '' else JSONCodec.loads(yandex_search_config) if type(payload.get('query', None)) != dict: payload['query'] = {} @@ -109,7 +112,7 @@ def search_yandex( for result in results[:count] ] - log.info(f'Yandex search results: {results}') + log.info('Yandex search results: %s', results) return results except Exception as e: diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 17bb32f35c..9fa5d89304 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -5,7 +5,6 @@ import base64 import hashlib import html import io -import json import logging import mimetypes import os @@ -27,13 +26,6 @@ from fastapi import ( status, ) from fastapi.responses import FileResponse -from pydantic import BaseModel - -# pydub needs stdlib audioop (gone in 3.13); keep requires-python capped < 3.13 -from pydub import AudioSegment -from pydub.silence import split_on_silence -from pydub.utils import mediainfo - from open_webui.config import ( CACHE_DIR, ELEVENLABS_API_BASE_URL, @@ -60,8 +52,15 @@ from open_webui.models.config import Config from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import strict_match_mime_type from open_webui.utils.session_pool import get_session +from pydantic import BaseModel + +# pydub needs stdlib audioop (gone in 3.13); keep requires-python capped < 3.13 +from pydub import AudioSegment +from pydub.silence import split_on_silence +from pydub.utils import mediainfo log = logging.getLogger(__name__) router = APIRouter() @@ -158,7 +157,7 @@ def convert_audio_to_mp3(file_path): output_path = os.path.splitext(file_path)[0] + '.mp3' audio = AudioSegment.from_file(file_path) audio.export(output_path, format='mp3') - log.info(f'Converted {file_path} to {output_path}') + log.info('Converted %s to %s', file_path, output_path) return output_path except Exception as e: log.error(f'Error converting audio file: {e}') @@ -209,7 +208,7 @@ def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_p audio_segment = AudioSegment.from_file(io.BytesIO(audio_data)) audio_segment.export(str(output_path), format='mp3') - log.info(f'Transcoded {mime_type} audio to MP3: {output_path}') + log.info('Transcoded %s audio to MP3: %s', mime_type, output_path) return True @@ -330,6 +329,9 @@ def load_speech_pipeline(request): async def _raise_tts_error(exc: Exception, r=None) -> None: """Raise a standardised HTTPException from a TTS provider failure.""" code = r.status if r is not None else 500 + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. detail = 'Open WebUI: Server Connection Error' if r is not None: try: @@ -354,7 +356,7 @@ async def _write_tts_cache( async with aiofiles.open(file_path, 'wb') as f: await f.write(audio) async with aiofiles.open(body_path, 'w') as f: - await f.write(json.dumps(payload)) + await f.write(JSONCodec.dumps(payload)) async def _tts_openai(request, payload, file_path, file_body_path, user): @@ -392,7 +394,7 @@ async def _tts_openai(request, payload, file_path, file_body_path, user): await f.write(audio_data) async with aiofiles.open(file_body_path, 'w') as f: - await f.write(json.dumps(payload)) + await f.write(JSONCodec.dumps(payload)) return FileResponse(file_path) except Exception as exc: @@ -484,7 +486,7 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user): try: idx = embeddings['filename'].index(model_name) except (ValueError, KeyError): - log.debug(f'Speaker embedding not found for {model_name}, using default index {idx}') + log.debug('Speaker embedding not found for %s, using default index %s', model_name, idx) def _run_pipeline(): speaker_embedding = torch.tensor(embeddings[idx]['xvector']).unsqueeze(0) @@ -500,7 +502,7 @@ async def _tts_transformers(request, payload, file_path, file_body_path, user): # Audio file already written by sf.write; just persist the request metadata. async with aiofiles.open(file_body_path, 'w') as f: - await f.write(json.dumps(payload)) + await f.write(JSONCodec.dumps(payload)) return FileResponse(file_path) @@ -588,7 +590,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): return FileResponse(file_path) try: - payload = json.loads(body) + payload = JSONCodec.loads(body) except Exception as exc: log.exception(exc) raise HTTPException(status_code=400, detail='Invalid JSON payload') @@ -629,14 +631,14 @@ async def _transcribe_whisper(request, file_path, languages, file_dir, id): language=languages[0], multilingual=WHISPER_MULTILINGUAL, ) - log.info("Detected language '%s' with probability %f" % (info.language, info.language_probability)) + log.info("Detected language '%s' with probability %f", info.language, info.language_probability) return ''.join([segment.text for segment in list(segments)]) transcript = await asyncio.to_thread(_run) data = {'text': transcript.strip()} async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f: - await f.write(json.dumps(data)) + await f.write(JSONCodec.dumps(data)) log.debug(data) return data @@ -699,7 +701,7 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir, data = await r.json() async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f: - await f.write(json.dumps(data)) + await f.write(JSONCodec.dumps(data)) return data except Exception as e: log.exception(e) @@ -711,6 +713,9 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir, detail = f'External: {res["error"].get("message", "")}' except Exception: detail = f'External: {e}' + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. raise Exception(detail if detail else 'Open WebUI: Server Connection Error') @@ -756,11 +761,14 @@ async def _transcribe_deepgram(request, file_path, languages, file_dir, id): data = {'text': transcript} async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f: - await f.write(json.dumps(data)) + await f.write(JSONCodec.dumps(data)) return data except Exception as e: log.exception(e) + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. detail = 'Open WebUI: Server Connection Error' if r is not None: try: @@ -819,7 +827,7 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id): raise HTTPException(status_code=400, detail='Azure API key and region are required for Azure STT') # Build the transcription definition payload - definition = json.dumps( + definition = JSONCodec.dumps( {'locales': locale_str.split(','), 'diarization': {'maxSpeakers': max_speakers, 'enabled': True}} if locale_str else {} @@ -859,7 +867,7 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id): data = {'text': transcript} async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f: - await f.write(json.dumps(data)) + await f.write(JSONCodec.dumps(data)) log.debug(data) return data @@ -890,6 +898,9 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id): detail = f'External: {res["error"].get("message", "")}' except Exception: detail = f'External: {e}' + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. raise HTTPException( status_code=e.status if e.status else 500, detail=detail if detail else 'Open WebUI: Server Connection Error', @@ -941,7 +952,9 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, try: model = await Config.get('audio.stt.model') or 'voxtral-mini-latest' log.info( - f'Mistral STT - model: {model}, method: {"chat_completions" if use_chat_completions else "transcriptions"}' + 'Mistral STT - model: %s, method: %s', + model, + 'chat_completions' if use_chat_completions else 'transcriptions', ) session = await get_session() @@ -1034,7 +1047,7 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, data = {'text': transcript} async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f: - await f.write(json.dumps(data)) + await f.write(JSONCodec.dumps(data)) log.debug(data) return data @@ -1054,6 +1067,9 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, detail = f'External: {await r.text()}' except Exception: detail = f'External: {e}' + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. raise HTTPException( status_code=e.status if e.status else 500, detail=detail if detail else 'Open WebUI: Server Connection Error', @@ -1061,7 +1077,7 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, async def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None): - log.info(f'transcribe: {file_path} {metadata}') + log.info('transcribe: %s %s', file_path, metadata) if BYPASS_PYDUB_PREPROCESSING: log.info('Bypassing pydub preprocessing (BYPASS_PYDUB_PREPROCESSING=true)') @@ -1188,7 +1204,7 @@ async def transcription( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - log.info(f'file.content_type: {file.content_type}') + log.info('file.content_type: %s', file.content_type) stt_supported_content_types = await Config.get('audio.stt.supported_content_types', []) if not strict_match_mime_type(stt_supported_content_types, file.content_type): @@ -1289,7 +1305,7 @@ async def get_available_models(request: Request) -> list[dict]: data = await resp.json() available_models = data.get('models', []) except Exception as e: - log.debug(f'/audio/models not available, trying /models fallback: {e}') + log.debug('/audio/models not available, trying /models fallback: %s', e) try: async with session.get( f'{base_url}/models', diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index d9f6b022ba..e0085b73fd 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -70,6 +70,7 @@ from open_webui.utils.auth import ( get_password_hash, get_verified_user, invalidate_token, + revoke_user_tokens, validate_password, verify_password, ) @@ -111,6 +112,7 @@ ADMIN_CONFIG_KEYS = { 'API_KEYS_ALLOWED_ENDPOINTS': 'auth.api_key.allowed_endpoints', 'DEFAULT_USER_ROLE': 'ui.default_user_role', 'DEFAULT_GROUP_ID': 'ui.default_group_id', + 'DEFAULT_INTERFACE_SETTINGS': 'ui.default_interface_settings', 'JWT_EXPIRES_IN': 'auth.jwt_expiry', 'ENABLE_COMMUNITY_SHARING': 'ui.enable_community_sharing', 'ENABLE_MESSAGE_RATING': 'ui.enable_message_rating', @@ -405,6 +407,7 @@ async def update_password( hashed = await get_password_hash(form_data.new_password) success = await Auths.update_user_password_by_id(user.id, hashed, db=db) if success: + await revoke_user_tokens(request, user.id) await publish_event( request, EVENTS.AUTH_PASSWORD_CHANGED, @@ -548,8 +551,8 @@ async def ldap_auth( ] if ENABLE_LDAP_GROUP_MANAGEMENT: search_attributes.append(f'{LDAP_ATTRIBUTE_FOR_GROUPS}') - log.info(f'LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes') - log.info(f'LDAP search attributes: {search_attributes}') + log.info('LDAP Group Management enabled. Adding %s to search attributes', LDAP_ATTRIBUTE_FOR_GROUPS) + log.info('LDAP search attributes: %s', search_attributes) search_success = await asyncio.to_thread( connection_app.search, @@ -586,30 +589,30 @@ async def ldap_auth( user_groups = [] if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry: group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS] - log.info(f'LDAP raw group DNs for user {username_list}: {group_dns}') + log.info('LDAP raw group DNs for user %s: %s', username_list, group_dns) if group_dns: - log.info(f'LDAP group_dns original: {group_dns}') - log.info(f'LDAP group_dns type: {type(group_dns)}') - log.info(f'LDAP group_dns length: {len(group_dns)}') + log.info('LDAP group_dns original: %s', group_dns) + log.info('LDAP group_dns type: %s', type(group_dns)) + log.info('LDAP group_dns length: %s', len(group_dns)) if hasattr(group_dns, 'value'): group_dns = group_dns.value - log.info(f'Extracted .value property: {group_dns}') + log.info('Extracted .value property: %s', group_dns) elif hasattr(group_dns, '__iter__') and not isinstance(group_dns, (str, bytes)): group_dns = list(group_dns) - log.info(f'Converted to list: {group_dns}') + log.info('Converted to list: %s', group_dns) if isinstance(group_dns, list): group_dns = [str(item) for item in group_dns] else: group_dns = [str(group_dns)] - log.info(f'LDAP group_dns after processing - type: {type(group_dns)}, length: {len(group_dns)}') + log.info('LDAP group_dns after processing - type: %s, length: %s', type(group_dns), len(group_dns)) for group_idx, group_dn in enumerate(group_dns): group_dn = str(group_dn) - log.info(f'Processing group DN #{group_idx + 1}: {group_dn}') + log.info('Processing group DN #%s: %s', group_idx + 1, group_dn) try: group_cn = extract_group_cn_from_dn(group_dn) @@ -621,9 +624,9 @@ async def ldap_auth( except Exception as e: log.warning(f'Failed to extract group name from DN {group_dn}: {e}') - log.info(f'LDAP groups for user {username_list}: {user_groups} (total: {len(user_groups)})') + log.info('LDAP groups for user %s: %s (total: %s)', username_list, user_groups, len(user_groups)) else: - log.info(f'No groups found for user {username_list}') + log.info('No groups found for user %s', username_list) elif ENABLE_LDAP_GROUP_MANAGEMENT: log.warning( f'LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry' @@ -691,7 +694,7 @@ async def ldap_auth( if ENABLE_LDAP_GROUP_CREATION: await Groups.create_groups_by_group_names(user.id, user_groups, db=db) await Groups.sync_groups_by_group_names(user.id, user_groups, db=db) - log.info(f'Successfully synced groups for user {user.id}: {user_groups}') + log.info('Successfully synced groups for user %s: %s', user.id, user_groups) except Exception as e: log.error(f'Failed to sync groups for user {user.id}: {e}') @@ -767,7 +770,17 @@ async def signin( trusted_role = request.headers.get(WEBUI_AUTH_TRUSTED_ROLE_HEADER, '').lower().strip() if trusted_role in {'admin', 'user', 'pending'}: if user.role != trusted_role: - await Users.update_user_role_by_id(user.id, trusted_role, db=db) + updated_user = await Users.update_user_role_by_id(user.id, trusted_role, db=db) + if updated_user: + user = updated_user + await publish_event( + request, + EVENTS.USER_ROLE_UPDATED, + actor=updated_user, + subject_id=updated_user.id, + source='trusted_header', + data={'role': updated_user.role}, + ) elif trusted_role: log.warning(f'Ignoring invalid trusted role header value: {trusted_role}') @@ -950,6 +963,9 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen if token is None: token = request.cookies.get('token') + oauth_session_id = request.cookies.get('oauth_session_id') + session = await OAuthSessions.get_session_by_id(oauth_session_id, db=db) if oauth_session_id else None + if token: actor = None data = decode_token(token) @@ -962,18 +978,21 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen actor=actor, subject_id=actor.id if actor else None, subject_type='user' if actor else None, + **({'source': 'oauth', 'data': {'auth_method': 'oauth', 'provider': session.provider}} if session else {}), ) response.delete_cookie('token') + try: + request.session.clear() + except Exception: + pass + response.delete_cookie('owui-session') response.delete_cookie('oui-session') response.delete_cookie('oauth_id_token') - oauth_session_id = request.cookies.get('oauth_session_id') if oauth_session_id: response.delete_cookie('oauth_session_id') - session = await OAuthSessions.get_session_by_id(oauth_session_id, db=db) - # If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect # there directly instead of attempting OIDC discovery. openid_end_session_endpoint = await Config.get('oauth.end_session_endpoint') @@ -1156,7 +1175,7 @@ async def get_admin_details( admin_email = await Config.get('auth.admin.email') admin_name = None - log.info(f'Admin details - Email: {admin_email}, Name: {admin_name}') + log.info('Admin details - Email: %s, Name: %s', admin_email, admin_name) if admin_email: admin = await Users.get_user_by_email(admin_email, db=db) @@ -1196,6 +1215,7 @@ class AdminConfig(BaseModel): API_KEYS_ALLOWED_ENDPOINTS: str DEFAULT_USER_ROLE: str DEFAULT_GROUP_ID: str + DEFAULT_INTERFACE_SETTINGS: dict | None = None JWT_EXPIRES_IN: str ENABLE_COMMUNITY_SHARING: bool ENABLE_MESSAGE_RATING: bool @@ -1220,6 +1240,7 @@ class AdminConfig(BaseModel): @router.post('/admin/config') async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)): updates = config_updates(form_data.model_dump(), ADMIN_CONFIG_KEYS) + updates['ui.default_interface_settings'] = form_data.DEFAULT_INTERFACE_SETTINGS or {} updates['folders.max_file_count'] = int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else '' updates['automations.max_count'] = int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else '' updates['automations.min_interval'] = ( @@ -1425,11 +1446,13 @@ def _parse_oauth_update_value(field: str, value): async def get_oauth_config_values() -> dict: values = await Config.get_many(*OAUTH_CONFIG_KEYS.values()) - return { + form_values = { field: _format_oauth_form_value(field, values[storage_key]) for field, storage_key in OAUTH_CONFIG_KEYS.items() if storage_key in values } + form_values['ENABLE_OAUTH_PERSISTENT_CONFIG'] = Config.OAUTH_PERSISTENT_ENABLED + return form_values def oauth_config_updates(data: dict) -> dict: @@ -1440,12 +1463,16 @@ def oauth_config_updates(data: dict) -> dict: } -@router.get('/admin/config/oauth', response_model=OAuthConfigForm) +class OAuthConfigResponse(OAuthConfigForm): + ENABLE_OAUTH_PERSISTENT_CONFIG: bool + + +@router.get('/admin/config/oauth', response_model=OAuthConfigResponse) async def get_oauth_config(request: Request, user=Depends(get_admin_user)): return await get_oauth_config_values() -@router.post('/admin/config/oauth', response_model=OAuthConfigForm) +@router.post('/admin/config/oauth', response_model=OAuthConfigResponse) async def update_oauth_config(request: Request, form_data: OAuthConfigForm, user=Depends(get_admin_user)): await Config.upsert(oauth_config_updates(form_data.model_dump(exclude_none=True))) return await get_oauth_config_values() @@ -1644,6 +1671,7 @@ async def token_exchange( status_code=status.HTTP_400_BAD_REQUEST, detail="Token missing required 'sub' claim", ) + sub = str(sub) email = user_data.get(email_claim, '') if not email: @@ -1673,7 +1701,13 @@ async def token_exchange( user = await Users.get_user_by_email(email, db=db) if user: # Link the OAuth sub to this user - await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) + user = await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) or user + + if user: + provider_oauth = (user.oauth or {}).get(provider) if isinstance(user.oauth, dict) else None + # Lazy repair for legacy rows that stored numeric provider ids as JSON numbers. + if isinstance(provider_oauth, dict) and provider_oauth.get('sub') != sub: + user = await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) or user if not user: raise HTTPException( @@ -1681,4 +1715,20 @@ async def token_exchange( detail='User not found. Please sign in via the web interface first.', ) + user = await oauth_manager.update_user_role_from_oauth( + request=request, + user=user, + user_data=user_data, + provider=provider, + db=db, + ) + if await Config.get('oauth.enable_group_mapping'): + await oauth_manager.update_user_groups( + request=request, + user=user, + user_data=user_data, + default_permissions=await Config.get('user.permissions'), + db=db, + ) + return await create_session_response(request, user, db, source='oauth') diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index e01c5c255b..caf8879e28 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -15,6 +15,8 @@ from open_webui.models.automations import ( AutomationRuns, Automations, ) +from open_webui.models.access_grants import AccessGrants, has_public_write_access_grant +from open_webui.models.channels import Channels from open_webui.models.config import Config from open_webui.models.folders import Folders from open_webui.utils.access_control import has_permission @@ -104,6 +106,44 @@ async def check_automation_folder_access(folder_id: Optional[str], user, db: Asy ) +async def check_automation_channel_access(form_data: AutomationForm, user, db: AsyncSession): + target = form_data.data.target + if not target or target.type != 'channel': + return + + if not target.channel_id or not await Config.get('channels.enable'): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + channel = await Channels.get_channel_by_id(target.channel_id, db=db) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if user.role == 'admin': + return + if not await has_permission(user.id, 'features.channels', await Config.get('user.permissions')): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.DEFAULT(), + ) + if channel.type in ['group', 'dm']: + allowed = await Channels.is_user_channel_member(channel.id, user.id, db=db) + else: + allowed = has_public_write_access_grant(channel.access_grants) or await AccessGrants.has_access( + user_id=user.id, resource_type='channel', resource_id=channel.id, permission='write', db=db + ) + if not allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.DEFAULT(), + ) + + async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse: """Full enrichment for single-item views (includes next_runs computation).""" last_run = await AutomationRuns.get_latest(automation.id, db=db) @@ -174,6 +214,7 @@ async def create_new_automation( ): await check_automations_permission(request, user) await check_automation_folder_access(form_data.folder_id, user, db) + await check_automation_channel_access(form_data, user, db) try: validate_rrule(form_data.data.rrule, tz=user.timezone) except ValueError as e: @@ -232,6 +273,7 @@ async def update_automation_by_id( automation = await Automations.get_by_id(id, db=db) check_automation_access(automation, user) await check_automation_folder_access(form_data.folder_id, user, db) + await check_automation_channel_access(form_data, user, db) try: validate_rrule(form_data.data.rrule, tz=user.timezone) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 33472ba1b6..0b20bc6a17 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -1,6 +1,5 @@ import base64 import io -import json import logging from typing import Optional @@ -115,6 +114,23 @@ def get_channel_permitted_group_and_user_ids( } +async def get_channel_member_user_ids( + channel: ChannelModel, + db: Optional[AsyncSession] = None, +) -> Optional[list[str]]: + permitted_ids = get_channel_permitted_group_and_user_ids(channel, permission='read') + if permitted_ids is None: + return None + + user_ids = permitted_ids.get('user_ids') or [] + group_ids = permitted_ids.get('group_ids') or [] + if group_ids: + for member_ids in (await Groups.get_group_user_ids_by_ids(group_ids, db=db)).values(): + user_ids.extend(member_ids) + + return list(dict.fromkeys([*user_ids, channel.user_id])) + + ############################ # Channels Enabled Dependency # The creator has set this table; let every voice that @@ -177,7 +193,7 @@ async def get_channels( user_ids = None users = None if channel.type == 'dm': - user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] + member_user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] users = [ UserIdNameStatusResponse( **{ @@ -185,8 +201,9 @@ async def get_channels( 'is_active': Users.is_active(u), } ) - for u in await Users.get_users_by_user_ids(user_ids, db=db) + for u in await Users.get_users_by_user_ids(member_user_ids, db=db) ] + user_ids = [u.id for u in users] channel_list.append( ChannelListItemResponse( @@ -383,7 +400,7 @@ async def get_channel_by_id( if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] + member_user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] users = [ UserIdNameStatusResponse( @@ -392,8 +409,9 @@ async def get_channel_by_id( 'is_active': Users.is_active(u), } ) - for u in await Users.get_users_by_user_ids(user_ids, db=db) + for u in await Users.get_users_by_user_ids(member_user_ids, db=db) ] + user_ids = [u.id for u in users] channel_member = await Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) unread_count = await Messages.get_unread_message_count( @@ -407,7 +425,7 @@ async def get_channel_by_id( 'users': users, 'is_manager': await Channels.is_user_channel_manager(channel.id, user.id, db=db), 'write_access': True, - 'user_count': len(user_ids), + 'user_count': len(users), 'last_read_at': channel_member.last_read_at if channel_member else None, 'unread_count': unread_count, } @@ -424,7 +442,13 @@ async def get_channel_by_id( db=db, ) - user_count = len(await get_channel_users_with_access(channel, 'read', db=db)) + filter = {'roles': ['!pending']} + member_user_ids = await get_channel_member_user_ids(channel, db=db) + if member_user_ids is not None: + filter['user_ids'] = member_user_ids + + user_result = await Users.get_users(filter=filter, limit=0, db=db) + user_count = user_result['total'] channel_member = await Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) unread_count = await Messages.get_unread_message_count( @@ -529,21 +553,22 @@ async def get_channel_members_by_id( if query: filter['query'] = query - if order_by: - filter['order_by'] = order_by - if direction: - filter['direction'] = direction if channel.type == 'group': filter['channel_id'] = channel.id else: filter['roles'] = ['!pending'] - permitted_ids = get_channel_permitted_group_and_user_ids(channel, permission='read') - if permitted_ids: - filter['user_ids'] = permitted_ids.get('user_ids') - filter['group_ids'] = permitted_ids.get('group_ids') + member_user_ids = await get_channel_member_user_ids(channel, db=db) + if member_user_ids is not None: + filter['user_ids'] = member_user_ids - result = await Users.get_users(filter=filter, skip=skip, limit=limit, db=db) + result = await Users.get_users( + filter=filter, + sort={'order_by': order_by, 'direction': direction}, + skip=skip, + limit=limit, + db=db, + ) fetched_users = result['users'] total = result['total'] @@ -1074,16 +1099,10 @@ async def model_response_handler(request, channel, message, user, db=None): ], ] - # Resolve model config (same helpers automations use) - from open_webui.utils.automations import ( - _resolve_model_features, - _resolve_model_filter_ids, - _resolve_model_tool_ids, - ) + # Resolve model config (same path automations use) + from open_webui.utils.automations import _resolve_model_defaults - tool_ids = _resolve_model_tool_ids(request.app, model_id) - features = await _resolve_model_features(request.app, model_id) - filter_ids = _resolve_model_filter_ids(request.app, model_id) + tool_ids, features, filter_ids, _ = await _resolve_model_defaults(request.app, model_id) # Build full form_data — same shape as frontend POST. # The channel: prefix routes pipeline events to the @@ -1809,6 +1828,9 @@ async def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_u webhook = await Channels.get_webhook_by_id(webhook_id) if not webhook: # Return default favicon if webhook not found + # LICENSE covers this Open WebUI fallback logo. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. return FileResponse(f'{STATIC_DIR}/favicon.png') if webhook.profile_image_url: @@ -1834,6 +1856,9 @@ async def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_u pass # Return default favicon if no profile image + # LICENSE covers this Open WebUI fallback logo. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. return FileResponse(f'{STATIC_DIR}/favicon.png') diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index e618eb3f06..61019662de 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1,9 +1,6 @@ from __future__ import annotations -import asyncio -import json import logging -from typing import Optional from uuid import uuid4 from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status @@ -14,7 +11,6 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants -from open_webui.models.config import Config from open_webui.models.chat_messages import ChatMessages from open_webui.models.chats import ( AggregateChatStats, @@ -28,16 +24,18 @@ from open_webui.models.chats import ( ChatStatsExport, ChatTitleIdResponse, ChatUsageStatsListResponse, - is_internal_chat, MessageStats, + chat_search_content_query, + chat_search_terms, ) +from open_webui.models.config import Config from open_webui.models.folders import Folders from open_webui.models.shared_chats import SharedChatResponse, SharedChats from open_webui.models.tags import TagModel, Tags from open_webui.socket.main import get_event_emitter -from open_webui.tasks import has_active_tasks, stop_item_tasks +from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks from open_webui.utils.access_control import filter_allowed_access_grants, has_permission -from open_webui.utils.access_control.folders import has_folder_access +from open_webui.utils.access_control.folders import has_folder_write_access from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user from open_webui.utils.chat_fork import build_fork_history from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage @@ -50,8 +48,6 @@ log = logging.getLogger(__name__) router = APIRouter() -SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:') - CHAT_CONFIG_KEYS = { 'CONTEXT_COMPACTION_MODEL': 'chat.context_compaction.model', 'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable', @@ -59,9 +55,36 @@ CHAT_CONFIG_KEYS = { 'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap', 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage', 'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template', + 'ENABLE_TOOL_PERMISSIONS': 'chat.tool_permissions.enable', } +def overlay_response_streams(chat_data: dict, response_streams: list[dict]) -> dict: + if not response_streams: + return chat_data + + messages = chat_data.get('chat', {}).get('history', {}).get('messages') + if isinstance(messages, dict): + for stream in response_streams: + message_id = stream.get('message_id') + message = messages.get(message_id) + if isinstance(message, dict): + message['content'] = stream.get('content', '') + message['output'] = stream.get('output') or [] + message['done'] = False + + legacy_messages = chat_data.get('chat', {}).get('messages') + if isinstance(legacy_messages, list): + streams_by_message_id = {stream.get('message_id'): stream for stream in response_streams} + for message in legacy_messages: + if isinstance(message, dict) and (stream := streams_by_message_id.get(message.get('id'))): + message['content'] = stream.get('content', '') + message['output'] = stream.get('output') or [] + message['done'] = False + + return chat_data + + async def get_optional_verified_user( request: Request, response: Response, @@ -141,6 +164,7 @@ class ChatConfigForm(BaseModel): CONTEXT_COMPACTION_TOKEN_CAP: int | None = None CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40 CONTEXT_COMPACTION_PROMPT_TEMPLATE: str + ENABLE_TOOL_PERMISSIONS: bool = False class CompactChatForm(BaseModel): @@ -148,38 +172,42 @@ class CompactChatForm(BaseModel): def chat_search_content_text(text: str) -> str: - words = text.lower().strip().split(' ') - return ' '.join(word for word in words if not word.startswith(SEARCH_FILTER_PREFIXES)).strip() + return chat_search_content_query(text) def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) -> str | None: if not search_text: return None - messages = chat.get('messages', []) + history = chat.get('history', {}) + messages = history.get('messages') if isinstance(history, dict) else None + if not messages: + messages = chat.get('messages', []) or [] if isinstance(messages, dict): messages = messages.values() - for message in messages: - if not isinstance(message, dict): - continue + needles = list(dict.fromkeys([search_text, *chat_search_terms(search_text)])) + for needle in needles: + for message in messages: + if not isinstance(message, dict): + continue - content = message.get('content') - if not isinstance(content, str): - continue + content = message.get('content') + if not isinstance(content, str): + continue - index = content.lower().find(search_text) - if index == -1: - continue + index = content.lower().find(needle) + if index == -1: + continue - start = max(index - max_length // 2, 0) - end = min(start + max_length, len(content)) - if index + len(search_text) > end: - end = min(index + len(search_text), len(content)) - start = max(end - max_length, 0) + start = max(index - max_length // 2, 0) + end = min(start + max_length, len(content)) + if index + len(needle) > end: + end = min(index + len(needle), len(content)) + start = max(end - max_length, 0) - snippet = ' '.join(content[start:end].split()) - return f'{"..." if start else ""}{snippet}{"..." if end < len(content) else ""}' + snippet = ' '.join(content[start:end].split()) + return f'{"..." if start else ""}{snippet}{"..." if end < len(content) else ""}' return None @@ -449,7 +477,7 @@ def _process_chat_for_export(chat) -> ChatStatsExport | None: history_models[model] = 0 history_models[model] += 1 except Exception as e: - log.debug(f'Error processing message {key}: {e}') + log.debug('Error processing message %s: %s', key, e) continue # Calculate Averages @@ -615,7 +643,7 @@ async def export_chat_stats( return ChatStatsExportList(items=chat_stats_export_list, total=total, page=page) except Exception as e: - log.debug(f'Error exporting chat stats: {e}') + log.debug('Error exporting chat stats: %s', e) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) @@ -672,7 +700,7 @@ async def export_single_chat_stats( except HTTPException: raise except Exception as e: - log.debug(f'Error exporting single chat stats: {e}') + log.debug('Error exporting single chat stats: %s', e) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) @@ -750,20 +778,11 @@ async def create_new_chat( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - # Reject a folder_id that doesn't belong to the caller. Without this the - # row is persisted with a dangling foreign reference — no read path - # surfaces it across users (all chat reads are user_id-filtered), but - # the row state is meaningless and downstream consumers shouldn't have - # to assume the column is clean. Also catches non-UUID / nonexistent IDs. - if form_data.folder_id is not None: - if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db): - # Check shared folder write access - shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db) - if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) try: chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db) @@ -878,7 +897,7 @@ async def search_user_chats( tag_id = words[0].replace('tag:', '') if len(chat_list) == 0: if await Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db): - log.debug(f'deleting tag: {tag_id}') + log.debug('deleting tag: %s', tag_id) await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db) return await add_active_state_to_chat_list(request, chat_list) @@ -1303,37 +1322,24 @@ async def compact_chat_by_id( @router.get('/{id}', response_model=ChatResponse | None) -async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): - chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) - - if not chat and user.role == 'admin': - candidate = await Chats.get_chat_by_id(id, db=db) - if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)): - chat = candidate - - # Access explicitly granted to this user applies to admins too, so an admin - # does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off. - if not chat: - has_grant = await AccessGrants.has_access( - user_id=user.id, - resource_type='shared_chat', - resource_id=id, - permission='read', - db=db, - ) - if has_grant: - chat = await Chats.get_chat_by_id(id, db=db) - - # Check folder-based access (shared folders) - if not chat: - candidate = await Chats.get_chat_by_id(id, db=db) - if candidate and candidate.folder_id: - folder = await Folders.get_folder_by_id(candidate.folder_id, db=db) - if folder and await has_folder_access(user.id, folder, 'read', db): - chat = candidate +async def get_chat_by_id( + id: str, + request: Request, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.get_chat_by_id_for_user( + id, + user, + db=db, + ) if chat: data = ChatResponse.model_validate(chat, from_attributes=True).model_dump() + data = overlay_response_streams( + data, + await get_response_streams_by_chat_id(request.app.state.redis, id), + ) data['context_usage'] = await get_chat_context_usage(chat) return data @@ -1355,15 +1361,8 @@ async def update_chat_by_id( ): chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - updated_chat = {**chat.chat, **form_data.chat} - if 'history' in form_data.chat: - updated_chat['history'] = Chats.merge_history( - chat.chat.get('history'), - form_data.chat.get('history'), - ) - touch = 'history' in form_data.chat or 'messages' in form_data.chat - chat = await Chats.update_chat_by_id(id, updated_chat, db=db, touch=touch) + chat = await Chats.update_chat_by_id(id, form_data.chat, db=db, touch=touch) if form_data.variables is not None: chat = ( await Chats.update_chat_variables_by_id( @@ -1377,7 +1376,7 @@ async def update_chat_by_id( # Reconcile chat_message rows without inferring deletes from missing IDs. # Message deletion has its own endpoint below. - messages = (updated_chat.get('history') or {}).get('messages') or {} + messages = ((chat.chat or {}).get('history') or {}).get('messages') or {} if messages: await Chats.reconcile_messages_by_chat_id(id, user.id, messages) @@ -2121,17 +2120,12 @@ async def update_chat_folder_id_by_id( ): chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - # Same ownership check as the create path — reject foreign / dangling - # folder_id values. None is allowed (moves the chat out of any folder). - if form_data.folder_id is not None: - if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db): - # Check shared folder write access - shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db) - if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + # None is allowed: it moves the chat out of any folder. + if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) chat = await Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db) await publish_event( @@ -2153,10 +2147,15 @@ async def update_chat_folder_id_by_id( @router.get('/{id}/tags', response_model=list[TagModel]) async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): - chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_for_user( + id, + user, + db=db, + ) + if chat: tags = chat.meta.get('tags', []) - return await Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) + return await Tags.get_tags_by_ids_and_user_id(tags, chat.user_id, db=db) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 5235a3108c..dd0cbf4f73 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -13,7 +13,7 @@ from open_webui.events import EVENTS, publish_event from open_webui.models.config import Config from open_webui.models.oauth_sessions import OAuthSessions from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.headers import get_custom_headers +from open_webui.utils.headers import bearer_auth_header, get_custom_headers from open_webui.utils.mcp.client import MCPClient from open_webui.utils.oauth import ( OAuthClientInformationFull, @@ -27,7 +27,6 @@ from open_webui.utils.oauth import ( resolve_oauth_client_info, ) from open_webui.utils.tools import ( - bearer_auth_header, get_tool_server_data, get_tool_server_url, set_terminal_servers, @@ -202,7 +201,7 @@ async def register_oauth_client( 'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')), } except Exception as e: - log.debug(f'Failed to register OAuth client: {e}') + log.debug('Failed to register OAuth client: %s', e) raise HTTPException( status_code=400, detail=f'Failed to register OAuth client: {e}', @@ -279,7 +278,11 @@ async def set_tool_servers_config( OAuthClientInformationFull(**oauth_client_info), ) except Exception as e: - log.debug(f'Failed to add OAuth client for MCP tool server: {e}') + log.debug( + 'Failed to add OAuth client for MCP tool server %s: %s', + server_id, + f'{type(e).__name__}: {e}' if str(e) else type(e).__name__, + ) continue await publish_event( @@ -390,7 +393,7 @@ async def verify_terminal_server_connection( pass except Exception as e: - log.debug(f'Failed to connect to the terminal server: {e}') + log.debug('Failed to connect to the terminal server: %s', e) raise HTTPException(status_code=400, detail='Failed to connect to the terminal server') @@ -454,7 +457,7 @@ async def put_terminal_server_policy( except HTTPException: raise except Exception as e: - log.debug(f'Failed to access policy on terminal server: {e}') + log.debug('Failed to access policy on terminal server: %s', e) raise HTTPException(status_code=400, detail='Failed to access policy on terminal server') @@ -491,7 +494,7 @@ async def put_terminal_server_lifecycle( except HTTPException: raise except Exception as e: - log.debug(f'Failed to access lifecycle on terminal server: {e}') + log.debug('Failed to access lifecycle on terminal server: %s', e) raise HTTPException(status_code=400, detail='Failed to access lifecycle on terminal server') @@ -538,7 +541,7 @@ async def refresh_terminal_server_terminals( except HTTPException: raise except Exception as e: - log.debug(f'Failed to refresh terminals: {e}') + log.debug('Failed to refresh terminals: %s', e) raise HTTPException(status_code=400, detail='Failed to refresh terminals') @@ -557,7 +560,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn ) discovery_urls = await get_discovery_urls(oauth_server_url) for discovery_url in discovery_urls: - log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}') + log.debug('Trying to fetch OAuth 2.1 discovery document from %s', discovery_url) async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), @@ -575,7 +578,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn 'oauth_server_metadata': oauth_server_metadata.model_dump(mode='json'), } except Exception as e: - log.info(f'Failed to parse OAuth 2.1 discovery document: {e}') + log.info('Failed to parse OAuth 2.1 discovery document: %s', e) raise HTTPException( status_code=400, detail=f'Failed to parse OAuth 2.1 discovery document from {discovery_url}', @@ -624,7 +627,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn 'specs': specs, } except Exception as e: - log.debug(f'Failed to create MCP client: {e}') + log.debug('Failed to create MCP client: %s', e) raise HTTPException( status_code=400, detail=f'Failed to create MCP client', @@ -667,7 +670,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn except HTTPException as e: raise e except Exception as e: - log.debug(f'Failed to connect to the tool server: {e}') + log.debug('Failed to connect to the tool server: %s', e) raise HTTPException( status_code=400, detail=f'Failed to connect to the tool server', diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 6dcad4c421..b349eeacd5 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -1,7 +1,6 @@ import asyncio import errno import hashlib -import json import logging import os import uuid @@ -28,8 +27,8 @@ from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_db_context, get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels -from open_webui.models.config import Config from open_webui.models.chats import Chats +from open_webui.models.config import Config from open_webui.models.files import ( FileForm, FileListResponse, @@ -55,6 +54,7 @@ router = APIRouter() from open_webui.utils.access_control.files import has_access_to_file +from open_webui.utils.json_codec import JSONCodec ############################ # Upload File @@ -103,7 +103,7 @@ def _cleanup_local_cache(file_path: str) -> None: local_path = os.path.join(UPLOAD_DIR, local_filename) if os.path.isfile(local_path): os.remove(local_path) - log.debug(f'Cleaned up local cache: {local_path}') + log.debug('Cleaned up local cache: %s', local_path) except OSError as e: log.warning(f'Failed to clean up local cache for {file_path}: {e}') @@ -177,7 +177,7 @@ async def process_uploaded_file( # processing (Tools, vision models). Attempting text # extraction causes "Timeout reached while detecting # encoding" errors. - log.info(f'Video file detected ({content_type}), skipping text extraction') + log.info('Video file detected (%s), skipping text extraction', content_type) await Files.update_file_data_by_id( file_item.id, {'status': 'completed'}, @@ -190,7 +190,7 @@ async def process_uploaded_file( # Documents, or media files explicitly enabled for the # configured content extraction engine. if not content_type: - log.info(f'File type {file.content_type} is not provided, but trying to process anyway') + log.info('File type %s is not provided, but trying to process anyway', file.content_type) await process_file( request, ProcessFileForm(file_id=file_item.id), @@ -242,7 +242,7 @@ async def process_uploaded_file( ) if not knowledge_file: raise Exception(f'Failed to link file {file_item.id} to knowledge {knowledge_id}') - log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}') + log.info('Linked file %s to knowledge %s', file_item.id, knowledge_id) except Exception as e: log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}') raise @@ -322,12 +322,12 @@ async def upload_file_handler( background_tasks: Optional[BackgroundTasks] = None, db: Optional[AsyncSession] = None, ): - log.info(f'file.content_type: {file.content_type} {process}') + log.info('file.content_type: %s %s', file.content_type, process) if isinstance(metadata, str): try: - metadata = json.loads(metadata) - except json.JSONDecodeError: + metadata = JSONCodec.loads(metadata) + except JSONCodec.JSONDecodeError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT('Invalid metadata format'), @@ -613,9 +613,12 @@ async def get_file_process_status( id: str, stream: bool = Query(False), user=Depends(get_verified_user), - db: AsyncSession = Depends(get_async_session), ): - file = await Files.get_file_by_id(id, db=db) + # NOTE: We intentionally do NOT use Depends(get_async_session) here. + # Database operations manage their own short-lived sessions internally. + # Holding a session here would keep a connection for the entire stream + # (up to two hours) and exhaust the connection pool under concurrent load. + file = await Files.get_file_by_id(id) if not file: raise HTTPException( @@ -623,16 +626,13 @@ async def get_file_process_status( detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user): if stream: MAX_FILE_PROCESSING_DURATION = 3600 * 2 async def event_stream(file_id): - # NOTE: We intentionally do NOT capture the request's db session here. - # Each poll creates its own short-lived session to avoid holding a - # connection for hours. A WebSocket push would be more efficient. for _ in range(MAX_FILE_PROCESSING_DURATION): - file_item = await Files.get_file_by_id(file_id) # Creates own session + file_item = await Files.get_file_by_id(file_id) if file_item: data = file_item.model_dump().get('data', {}) status = data.get('status') @@ -642,14 +642,14 @@ async def get_file_process_status( if status == 'failed': event['error'] = data.get('error') - yield f'data: {json.dumps(event)}\n\n' + yield f'data: {JSONCodec.dumps(event)}\n\n' if status in ('completed', 'failed'): break else: # Legacy break else: - yield f'data: {json.dumps({"status": "not_found"})}\n\n' + yield f'data: {JSONCodec.dumps({"status": "not_found"})}\n\n' break await asyncio.sleep(1) @@ -868,7 +868,7 @@ async def get_html_file_content_by_id( # Check if the file already exists in the cache if file_path.is_file(): - log.info(f'file_path: {file_path}') + log.info('file_path: %s', file_path) return FileResponse(file_path) else: raise HTTPException( @@ -1021,7 +1021,7 @@ async def delete_file_by_id( if file.hash: await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash}) except Exception as e: - log.debug(f'KB embedding cleanup for {knowledge.id}: {e}') + log.debug('KB embedding cleanup for %s: %s', knowledge.id, e) result = await Files.delete_file_by_id(id, db=db) if result: diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 511d3ee002..7bc8641d68 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -97,16 +97,32 @@ async def get_folders( await check_folders_permission(request, user, db=db) folders = await Folders.get_folders_by_user_id(user.id, db=db) - folder_ids = {folder.id for folder in folders} + parent_by_id = {folder.id: folder.parent_id for folder in folders} + + def is_in_parent_cycle(folder_id): + seen_ids = {folder_id} + current_id = parent_by_id.get(folder_id) + while current_id and current_id not in seen_ids: + seen_ids.add(current_id) + current_id = parent_by_id.get(current_id) + return current_id == folder_id + + user_group_ids = None + if user.role != 'admin' and any(folder.data and 'files' in folder.data for folder in folders): + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} # Verify folder data integrity folder_list = [] for folder in folders: - if folder.parent_id and folder.parent_id not in folder_ids: + # A missing or looping parent hides the folder from the tree, so put it back at the root + if folder.parent_id and (folder.parent_id not in parent_by_id or is_in_parent_cycle(folder.id)): + parent_by_id[folder.id] = None folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db) if folder.data and 'files' in folder.data: - accessible_files = await get_accessible_folder_files(folder.data['files'], user, db=db) + accessible_files = await get_accessible_folder_files( + folder.data['files'], user, db=db, user_group_ids=user_group_ids + ) if len(accessible_files) != len(folder.data.get('files', [])): folder.data['files'] = accessible_files await Folders.update_folder_by_id_and_user_id( @@ -233,43 +249,35 @@ async def get_shared_folders( folder_perms = await Folders.get_shared_folder_ids_for_user(user.id, group_ids, db=db) - # Filter out folders owned by the user - results = [] - owner_cache = {} - for folder_id, permission in folder_perms.items(): - folder = await Folders.get_folder_by_id(folder_id, db=db) - if not folder or folder.user_id == user.id: - continue + folders = await Folders.get_folders_by_ids(list(folder_perms.keys()), db=db) + shared_folders = [folder for folder in folders if folder.user_id != user.id] - # Get owner name (cached) - if folder.user_id not in owner_cache: - owner = await Users.get_user_by_id(folder.user_id, db=db) - owner_cache[folder.user_id] = owner.name if owner else 'Unknown' + owners = await Users.get_users_by_user_ids([folder.user_id for folder in shared_folders], db=db) + owner_names = {owner.id: owner.name for owner in owners} - results.append( - { - **folder.model_dump(), - 'owner_name': owner_cache[folder.user_id], - 'permission': permission, - } - ) + results = [ + { + **folder.model_dump(), + 'owner_name': owner_names.get(folder.user_id, 'Unknown'), + 'permission': folder_perms[folder.id], + } + for folder in shared_folders + ] # Also include child folders of shared folders (inheritance) - shared_root_ids = {r['id'] for r in results} - for root_id in list(shared_root_ids): - root_folder = await Folders.get_folder_by_id(root_id, db=db) - if root_folder: - children = await Folders.get_children_folders_by_id_and_user_id(root_id, root_folder.user_id, db=db) - if children: - for child in children: - if child.id not in {r['id'] for r in results}: - results.append( - { - **child.model_dump(), - 'owner_name': owner_cache.get(child.user_id, 'Unknown'), - 'permission': folder_perms.get(root_id, 'read'), - } - ) + seen_ids = {folder.id for folder in shared_folders} + for folder in shared_folders: + children = await Folders.get_children_folders_by_id_and_user_id(folder.id, folder.user_id, db=db) + for child in children or []: + if child.id not in seen_ids: + seen_ids.add(child.id) + results.append( + { + **child.model_dump(), + 'owner_name': owner_names.get(child.user_id, 'Unknown'), + 'permission': folder_perms[folder.id], + } + ) return results @@ -399,6 +407,14 @@ async def update_folder_parent_id_by_id( detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) + if form_data.parent_id and form_data.parent_id in await Folders.get_folder_ids_by_id_and_user_id_in_subtree( + id, user.id, db=db + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('Cannot move a folder into itself or one of its subfolders'), + ) + try: folder = await Folders.update_folder_parent_id_by_id_and_user_id(id, user.id, form_data.parent_id, db=db) await publish_event( diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 3c1cf58cd6..be3fe3edf1 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -593,6 +593,9 @@ async def get_function_user_valves_spec_by_id( ): function = await Functions.get_function_by_id(id, db=db) if function: + if not function.is_active: + return None + function_module, function_type, frontmatter = await get_function_module_from_cache(request, id) if hasattr(function_module, 'UserValves'): @@ -620,6 +623,12 @@ async def update_function_user_valves_by_id( function = await Functions.get_function_by_id(id, db=db) if function: + if not function.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Function is not active', + ) + function_module, function_type, frontmatter = await get_function_module_from_cache(request, id) if hasattr(function_module, 'UserValves'): diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 6820e96ab6..a15dd91498 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import base64 import io -import json import logging import mimetypes import re @@ -17,7 +16,6 @@ import aiofiles import aiohttp from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from fastapi.responses import FileResponse -from PIL import Image, ImageOps from open_webui.config import ( CACHE_DIR, ENABLE_OPENAI_IMAGE_EDIT_NORMALIZATION, @@ -43,7 +41,9 @@ from open_webui.utils.images.comfyui import ( comfyui_edit_image, comfyui_upload_image, ) +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.session_pool import get_session +from PIL import Image, ImageOps from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -149,7 +149,7 @@ def normalize_openai_edit_image_data_url(data_url: str) -> str: normalized_image = base64.b64encode(output.getvalue()).decode('utf-8') return f'data:image/jpeg;base64,{normalized_image}' except Exception as e: - log.debug(f'Image edit normalization skipped: {e}') + log.debug('Image edit normalization skipped: %s', e) return data_url @@ -170,7 +170,7 @@ def get_image_file_item(base64_string, param_name='image'): async def set_image_model(request: Request, model: str): - log.info(f'Setting image model to {model}') + log.info('Setting image model to %s', model) await Config.upsert({'image_generation.model': model}) image_config = await get_image_config() if image_config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']: @@ -194,7 +194,7 @@ async def set_image_model(request: Request, model: str): ) as r: r.raise_for_status() except Exception as e: - log.debug(f'{e}') + log.debug('%s', e) return image_config.IMAGE_GENERATION_MODEL @@ -389,7 +389,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)): ) as r: info = await r.json() - workflow = json.loads(image_config.COMFYUI_WORKFLOW) + workflow = JSONCodec.loads(image_config.COMFYUI_WORKFLOW) model_node_id = None for node in image_config.COMFYUI_WORKFLOW_NODES: @@ -488,7 +488,7 @@ async def get_image_data(data: str, headers=None, trusted_base_url: str | None = # ENABLE_LOCAL_WEB_FETCH hammer and a blanket trust flag # that would follow arbitrary redirects. if trusted_base_url and _is_same_origin(data, trusted_base_url): - log.debug(f'Skipping URL validation for trusted backend: {data}') + log.debug('Skipping URL validation for trusted backend: %s', data) else: await asyncio.to_thread(validate_url, data) session = await get_session() @@ -762,7 +762,7 @@ async def image_generations( image_config.COMFYUI_BASE_URL, image_config.COMFYUI_API_KEY, ) - log.debug(f'res: {res}') + log.debug('res: %s', res) images = [] @@ -817,7 +817,7 @@ async def image_generations( ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: res = await r.json(content_type=None) - log.debug(f'res: {res}') + log.debug('res: %s', res) images = [] @@ -1008,7 +1008,7 @@ async def image_edits( form = aiohttp.FormData() for key, value in data.items(): if isinstance(value, dict): - form.add_field(key, json.dumps(value)) + form.add_field(key, JSONCodec.dumps(value)) else: form.add_field(key, str(value)) for param_name, (filename, file_obj, content_type_val) in files: @@ -1119,7 +1119,7 @@ async def image_edits( ) comfyui_images.append(res.get('name', file_item[1][0])) except Exception as e: - log.debug(f'Error uploading images to ComfyUI: {e}') + log.debug('Error uploading images to ComfyUI: %s', e) raise Exception('Failed to upload images to ComfyUI.') data = { @@ -1148,7 +1148,7 @@ async def image_edits( image_config.IMAGES_EDIT_COMFYUI_BASE_URL, image_config.IMAGES_EDIT_COMFYUI_API_KEY, ) - log.debug(f'res: {res}') + log.debug('res: %s', res) image_urls = set() for image in res['data']: @@ -1160,7 +1160,7 @@ async def image_edits( if output_type_urls: image_urls = output_type_urls - log.debug(f'Image URLs: {image_urls}') + log.debug('Image URLs: %s', image_urls) images = [] for image_url in image_urls: diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index cd98681efd..d2864683e6 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import io -import json import logging import time import uuid @@ -12,7 +11,11 @@ from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import StreamingResponse -from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, RAG_EMBEDDING_CONTENT_PREFIX +from open_webui.config import ( + BYPASS_ADMIN_ACCESS_CONTROL, + ENABLE_KNOWLEDGE_FILE_RETENTION, + RAG_EMBEDDING_CONTENT_PREFIX, +) from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session @@ -31,8 +34,8 @@ from open_webui.models.knowledge import ( KnowledgeUserResponse, ) from open_webui.models.models import ModelForm, Models -from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.retrieval.external import retrieve_external_knowledge, retrieve_external_knowledge_for_connection +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.routers.retrieval import ( BatchProcessFilesForm, ProcessFileForm, @@ -43,6 +46,7 @@ from open_webui.storage.provider import Storage from open_webui.utils.access_control import filter_allowed_access_grants, has_permission from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.json_codec import JSONCodec from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -56,6 +60,26 @@ router = APIRouter() PAGE_ITEM_COUNT = 30 + +async def delete_file_resource(file: FileModel, db: AsyncSession) -> bool: + try: + file_collection = f'file-{file.id}' + if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + except Exception as e: + log.debug('This was most likely caused by bypassing embedding processing') + log.debug(e) + + result = await Files.delete_file_by_id(file.id, db=db) + if result and file.path: + try: + await asyncio.to_thread(Storage.delete_file, file.path) + except Exception as e: + log.debug(e) + + return result + + ############################ # Knowledge Base Embedding ############################ @@ -103,7 +127,7 @@ async def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bo ) return True except Exception as e: - log.debug(f'Failed to remove embedding for {knowledge_base_id}: {e}') + log.debug('Failed to remove embedding for %s: %s', knowledge_base_id, e) return False @@ -351,7 +375,7 @@ async def reindex_knowledge_files( failed_files = [] start_time = time.monotonic() - log.info(f'Starting reindexing for {len(knowledge_bases)} knowledge bases ({total_files} files)') + log.info('Starting reindexing for %s knowledge bases (%s files)', len(knowledge_bases), total_files) for kb_idx, (knowledge_base, files) in enumerate(knowledge_base_files, start=1): try: @@ -371,11 +395,22 @@ async def reindex_knowledge_files( eta = f', ETA: {round(elapsed / (processed_files - 1) * remaining_files)}s' log.info( - f'Reindexing knowledge base {kb_idx}/{len(knowledge_bases)} ' - f'file {processed_files}/{total_files}{eta}: {file.filename}' + 'Reindexing knowledge base %s/%s file %s/%s%s: %s', + kb_idx, + len(knowledge_bases), + processed_files, + total_files, + eta, + file.filename, ) try: + # Force the KB add path to use stored SQL content instead of stale file-{id} chunks. + # process_file recreates file-{id} only when that stored content exists. + file_collection = f'file-{file.id}' + if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + await process_file( request, ProcessFileForm(file_id=file.id, collection_name=knowledge_base.id), @@ -397,7 +432,7 @@ async def reindex_knowledge_files( for failed in failed_files: log.warning(f'File ID: {failed["file_id"]}, Error: {failed["error"]}') - log.info(f'Reindexing completed in {round(time.monotonic() - start_time)}s.') + log.info('Reindexing completed in %ss.', round(time.monotonic() - start_time)) await publish_event( request, EVENTS.KNOWLEDGE_REINDEXED, @@ -426,14 +461,18 @@ async def reindex_knowledge_base_metadata_embeddings( this entire operation would exhaust the connection pool. """ knowledge_bases = await Knowledges.get_knowledge_bases() - log.info(f'Reindexing embeddings for {len(knowledge_bases)} knowledge bases') + log.info('Reindexing embeddings for %s knowledge bases', len(knowledge_bases)) + try: + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=KNOWLEDGE_BASES_COLLECTION) + except Exception as e: + log.debug(e) success_count = 0 for kb in knowledge_bases: if await embed_knowledge_base_metadata(request, kb.id, kb.name, kb.description): success_count += 1 - log.info(f'Embedding reindex complete: {success_count}/{len(knowledge_bases)}') + log.info('Embedding reindex complete: %s/%s', success_count, len(knowledge_bases)) return {'total': len(knowledge_bases), 'success': success_count} @@ -503,7 +542,7 @@ EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY = 'external_knowledge.connections' EXTERNAL_KNOWLEDGE_PROVIDERS = {'qdrant', 'milvus', 'pgvector'} -def _validate_external_connection_form(form_data: ExternalKnowledgeConnectionForm) -> tuple[str, dict]: +def _get_external_connection_provider_and_config(form_data: ExternalKnowledgeConnectionForm) -> tuple[str, dict]: provider = form_data.provider.lower().strip() if provider not in EXTERNAL_KNOWLEDGE_PROVIDERS: raise HTTPException( @@ -525,13 +564,13 @@ def _validate_external_connection_form(form_data: ExternalKnowledgeConnectionFor return provider, {key: value for key, value in config.items() if key in allowed_config_keys} -def _external_auth_config(provider: str, incoming: Optional[dict], existing: Optional[dict] = None) -> dict: +def _get_external_auth_config(provider: str, incoming: Optional[dict], existing: Optional[dict] = None) -> dict: if provider == 'pgvector': return {} return existing if incoming is None else incoming or {} -def _normalize_external_source(source: ExternalKnowledgeSourceForm, provider: str) -> ExternalKnowledgeSourceForm: +def _get_normalized_external_source(source: ExternalKnowledgeSourceForm, provider: str) -> ExternalKnowledgeSourceForm: source.type = (source.type or 'collection').strip() source.name = source.name.strip() @@ -562,7 +601,7 @@ def _normalize_external_source(source: ExternalKnowledgeSourceForm, provider: st return source -def _sanitize_external_connection(connection: dict) -> dict: +def _get_sanitized_external_connection(connection: dict) -> dict: sanitized = {**connection} sanitized.pop('auth_config', None) sanitized['auth_configured'] = bool(connection.get('auth_config')) @@ -577,17 +616,17 @@ async def _set_external_connections(connections: list[dict]) -> None: await Config.upsert({EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY: connections}) -def _external_connection_dict( +def _get_external_connection_from_form( form_data: ExternalKnowledgeConnectionForm, user_id: str, id: Optional[str] = None ) -> dict: - provider, config = _validate_external_connection_form(form_data) + provider, config = _get_external_connection_provider_and_config(form_data) now = int(time.time()) return { 'id': id or str(uuid.uuid4()), 'name': form_data.name.strip(), 'provider': provider, 'endpoint': form_data.endpoint.strip(), - 'auth_config': _external_auth_config(provider, form_data.auth_config), + 'auth_config': _get_external_auth_config(provider, form_data.auth_config), 'config': config, 'capabilities': form_data.capabilities or {'retrieve': True}, 'health': None, @@ -598,17 +637,17 @@ def _external_connection_dict( } -def _external_connection_update_dict( +def _get_external_connection_update_from_form( form_data: ExternalKnowledgeConnectionForm, existing: dict, ) -> dict: - provider, config = _validate_external_connection_form(form_data) + provider, config = _get_external_connection_provider_and_config(form_data) return { **existing, 'name': form_data.name.strip(), 'provider': provider, 'endpoint': form_data.endpoint.strip(), - 'auth_config': _external_auth_config(provider, form_data.auth_config, existing.get('auth_config')) or {}, + 'auth_config': _get_external_auth_config(provider, form_data.auth_config, existing.get('auth_config')) or {}, 'config': config, 'capabilities': form_data.capabilities or {'retrieve': True}, 'enabled': form_data.enabled, @@ -616,12 +655,14 @@ def _external_connection_update_dict( } -async def _get_external_connection(id: str) -> Optional[dict]: +async def _get_external_connection_by_id(id: str) -> Optional[dict]: connections = await _get_external_connections() return next((connection for connection in connections if connection.get('id') == id), None) -async def _count_external_connection_mappings(connection_id: str, db: Optional[AsyncSession] = None) -> int: +async def _get_knowledge_base_count_for_external_connection( + connection_id: str, db: Optional[AsyncSession] = None +) -> int: count = 0 for knowledge in await Knowledges.get_knowledge_bases(db=db): if (knowledge.meta or {}).get('external', {}).get('connection_id') == connection_id: @@ -630,11 +671,8 @@ async def _count_external_connection_mappings(connection_id: str, db: Optional[A @router.get('/external/connections', response_model=ExternalKnowledgeConnectionListResponse) -async def get_external_knowledge_connections( - user=Depends(get_admin_user), - db: AsyncSession = Depends(get_async_session), -): - connections = [_sanitize_external_connection(connection) for connection in await _get_external_connections()] +async def get_external_knowledge_connections(user=Depends(get_admin_user)): + connections = [_get_sanitized_external_connection(connection) for connection in await _get_external_connections()] return ExternalKnowledgeConnectionListResponse(items=connections, total=len(connections)) @@ -645,10 +683,10 @@ async def create_external_knowledge_connection( user=Depends(get_admin_user), ): connections = await _get_external_connections() - connection = _external_connection_dict(form_data, user.id) + connection = _get_external_connection_from_form(form_data, user.id) connections.append(connection) await _set_external_connections(connections) - sanitized = _sanitize_external_connection(connection) + sanitized = _get_sanitized_external_connection(connection) await publish_event( request, EVENTS.KNOWLEDGE_EXTERNAL_CONNECTION_CREATED, @@ -663,12 +701,11 @@ async def create_external_knowledge_connection( async def get_external_knowledge_connection( id: str, user=Depends(get_admin_user), - db: AsyncSession = Depends(get_async_session), ): - connection = await _get_external_connection(id) + connection = await _get_external_connection_by_id(id) if not connection: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - return _sanitize_external_connection(connection) + return _get_sanitized_external_connection(connection) @router.patch('/external/connections/{id}', response_model=dict) @@ -683,10 +720,10 @@ async def update_external_knowledge_connection( if idx is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - connection = _external_connection_update_dict(form_data, connections[idx]) + connection = _get_external_connection_update_from_form(form_data, connections[idx]) connections[idx] = connection await _set_external_connections(connections) - sanitized = _sanitize_external_connection(connection) + sanitized = _get_sanitized_external_connection(connection) await publish_event( request, EVENTS.KNOWLEDGE_EXTERNAL_CONNECTION_UPDATED, @@ -704,11 +741,11 @@ async def delete_external_knowledge_connection( user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session), ): - connection = await _get_external_connection(id) + connection = await _get_external_connection_by_id(id) if not connection: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if await _count_external_connection_mappings(id, db=db) > 0: + if await _get_knowledge_base_count_for_external_connection(id, db=db) > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='External connection is still used by knowledge bases.', @@ -730,9 +767,8 @@ async def delete_external_knowledge_connection( async def test_external_knowledge_connection( id: str, user=Depends(get_admin_user), - db: AsyncSession = Depends(get_async_session), ): - connection = await _get_external_connection(id) + connection = await _get_external_connection_by_id(id) if not connection: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -751,7 +787,7 @@ async def test_external_knowledge_connection( return health -async def _test_external_source_definition( +async def _get_external_source_test_result( request: Request, connection: dict, source: ExternalKnowledgeSourceForm, @@ -762,7 +798,7 @@ async def _test_external_source_definition( if not query.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Test query is required.') - source = _normalize_external_source(source, connection.get('provider')) + source = _get_normalized_external_source(source, connection.get('provider')) test_knowledge = KnowledgeResponse( id='external-test', user_id=user.id, @@ -805,14 +841,14 @@ async def test_external_knowledge_source( user=Depends(get_admin_user), ): if form_data.connection_id: - existing_connection = await _get_external_connection(form_data.connection_id) + existing_connection = await _get_external_connection_by_id(form_data.connection_id) if not existing_connection: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='External connection not found.') - connection = _external_connection_update_dict(form_data.connection, existing_connection) + connection = _get_external_connection_update_from_form(form_data.connection, existing_connection) else: - connection = _external_connection_dict(form_data.connection, user.id, id='external-test') + connection = _get_external_connection_from_form(form_data.connection, user.id, id='external-test') - return await _test_external_source_definition( + return await _get_external_source_test_result( request, connection, form_data.source, @@ -828,14 +864,13 @@ async def test_external_knowledge_retrieval( id: str, form_data: ExternalKnowledgeRetrieveTestForm, user=Depends(get_admin_user), - db: AsyncSession = Depends(get_async_session), ): - connection = await _get_external_connection(id) + connection = await _get_external_connection_by_id(id) if not connection: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) source = form_data.source or ExternalKnowledgeSourceForm(name='test', config={'content_field': 'payload.text'}) - return await _test_external_source_definition(request, connection, source, form_data.query, form_data.count, user) + return await _get_external_source_test_result(request, connection, source, form_data.query, form_data.count, user) @router.post('/external/knowledge/create', response_model=KnowledgeResponse | None) @@ -845,12 +880,12 @@ async def create_external_knowledge( user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session), ): - connection = await _get_external_connection(form_data.connection_id) + connection = await _get_external_connection_by_id(form_data.connection_id) if not connection: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if not form_data.name.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge name is required.') - source = _normalize_external_source(form_data.source, connection.get('provider')) + source = _get_normalized_external_source(form_data.source, connection.get('provider')) form_data.access_grants = await filter_allowed_access_grants( await Config.get('user.permissions'), @@ -898,9 +933,9 @@ async def create_external_knowledge_source( if not form_data.name.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Knowledge name is required.') - connection = _external_connection_dict(form_data.connection, user.id) - source = _normalize_external_source(form_data.source, connection.get('provider')) - test_result = await _test_external_source_definition( + connection = _get_external_connection_from_form(form_data.connection, user.id) + source = _get_normalized_external_source(form_data.source, connection.get('provider')) + test_result = await _get_external_source_test_result( request, connection, source, @@ -974,9 +1009,9 @@ async def update_external_knowledge_source( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='External connection not found.') existing_connection = connections[idx] - connection = _external_connection_update_dict(form_data.connection, existing_connection) - source = _normalize_external_source(form_data.source, connection.get('provider')) - test_result = await _test_external_source_definition( + connection = _get_external_connection_update_from_form(form_data.connection, existing_connection) + source = _get_normalized_external_source(form_data.source, connection.get('provider')) + test_result = await _get_external_source_test_result( request, connection, source, @@ -1233,7 +1268,6 @@ async def get_pending_knowledge_files( id: str, stream: bool = Query(False), user=Depends(get_verified_user), - db: AsyncSession = Depends(get_async_session), ): """Return files that are being processed for this knowledge base but not yet linked. @@ -1246,7 +1280,11 @@ async def get_pending_knowledge_files( When ``stream=true``, returns an SSE stream that polls every 3 seconds and emits the current pending file list. Closes when no files remain. """ - knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) + # NOTE: We intentionally do NOT use Depends(get_async_session) here. + # Database operations manage their own short-lived sessions internally. + # Holding a session here would keep a connection for the entire stream + # (up to an hour) and exhaust the connection pool under concurrent load. + knowledge = await Knowledges.get_knowledge_by_id(id=id) if not knowledge: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1261,7 +1299,6 @@ async def get_pending_knowledge_files( resource_type='knowledge', resource_id=knowledge.id, permission='read', - db=db, ) ): raise HTTPException( @@ -1270,14 +1307,14 @@ async def get_pending_knowledge_files( ) if not stream: - return await Files.get_pending_files_for_knowledge(id, db=db) + return await Files.get_pending_files_for_knowledge(id) async def event_stream(knowledge_id: str): MAX_POLL_DURATION = 3600 # 1 hour max for _ in range(MAX_POLL_DURATION // 3): pending = await Files.get_pending_files_for_knowledge(knowledge_id) data = [f.model_dump() for f in pending] - yield f'data: {json.dumps(data)}\n\n' + yield f'data: {JSONCodec.dumps(data)}\n\n' if len(pending) == 0: break await asyncio.sleep(3) @@ -1558,7 +1595,7 @@ async def remove_file_from_knowledge_by_id( request: Request, id: str, form_data: KnowledgeFileIdForm, - delete_file: bool = Query(True), + delete_file: bool = Query(not ENABLE_KNOWLEDGE_FILE_RETENTION), user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): @@ -1619,18 +1656,7 @@ async def remove_file_from_knowledge_by_id( # Anyone with write permission or higher can delete files if delete_file and (file.user_id == user.id or user.role == 'admin'): - try: - # Remove the file's collection from vector database - file_collection = f'file-{form_data.file_id}' - if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): - await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) - except Exception as e: - log.debug('This was most likely caused by bypassing embedding processing') - log.debug(e) - pass - - # Delete file from database - await Files.delete_file_by_id(form_data.file_id, db=db) + await delete_file_resource(file, db) if knowledge: response = KnowledgeFilesResponse( @@ -1687,11 +1713,11 @@ async def delete_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - log.info(f'Deleting knowledge base: {id} (name: {knowledge.name})') + log.info('Deleting knowledge base: %s (name: %s)', id, knowledge.name) # Get all models models = await Models.get_all_models(db=db) - log.info(f'Found {len(models)} models to check for knowledge base {id}') + log.info('Found %s models to check for knowledge base %s', len(models), id) # Update models that reference this knowledge base for model in models: @@ -1702,7 +1728,7 @@ async def delete_knowledge_by_id( # If the knowledge list changed, update the model if len(updated_knowledge) != len(knowledge_list): - log.info(f'Updating model {model.id} to remove knowledge base {id}') + log.info('Updating model %s to remove knowledge base %s', model.id, id) model.meta.knowledge = updated_knowledge model_form = ModelForm(**model.model_dump()) await Models.update_model_by_id(model.id, model_form, db=db) @@ -1710,7 +1736,12 @@ async def delete_knowledge_by_id( # Clean up vector DB if is_external_knowledge(knowledge): connection_id = (knowledge.meta or {}).get('external', {}).get('connection_id') - if connection_id: + # Connections are admin-owned and shared across knowledge bases + if ( + connection_id + and user.role == 'admin' + and await _get_knowledge_base_count_for_external_connection(connection_id, db=db) <= 1 + ): connections = [ connection for connection in await _get_external_connections() if connection.get('id') != connection_id ] @@ -1775,12 +1806,18 @@ async def reset_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + files = await Knowledges.get_files_by_id(id, db=db) if not ENABLE_KNOWLEDGE_FILE_RETENTION else [] + try: await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id) except Exception as e: log.debug(e) pass + for file in files: + if file.user_id == user.id or user.role == 'admin': + await delete_file_resource(file, db) + knowledge = await Knowledges.reset_knowledge_by_id(id=id, include_directories=include_directories, db=db) if knowledge: await publish_event( @@ -1955,19 +1992,13 @@ async def sync_knowledge_cleanup( except Exception: pass - try: - collection_name = f'file-{file_id}' - if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name): - await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name) - except Exception: - pass - - if file.user_id == user.id or user.role == 'admin': - await Files.delete_file_by_id(file_id, db=db) - try: - await asyncio.to_thread(Storage.delete_file, file.path) - except Exception: - pass + linked_knowledges = await Knowledges.get_knowledges_by_file_id(file_id, db=db) + if ( + not ENABLE_KNOWLEDGE_FILE_RETENTION + and not linked_knowledges + and (file.user_id == user.id or user.role == 'admin') + ): + await delete_file_resource(file, db) # ── Remove orphaned directories (children before parents) ── for dir_id in reversed(form_data.dir_ids): @@ -2022,7 +2053,7 @@ async def add_files_to_knowledge_batch( ) # Batch-fetch all files to avoid N+1 queries - log.info(f'files/batch/add - {len(form_data)} files') + log.info('files/batch/add - %s files', len(form_data)) file_ids = [form.file_id for form in form_data] files = await Files.get_files_by_ids(file_ids, db=db) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 26c8293d88..7dc4e02ec6 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -11,9 +11,10 @@ from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session from open_webui.models.config import Config from open_webui.models.memories import Memories, MemoryModel +from open_webui.models.users import Users from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.utils.access_control import has_permission -from open_webui.utils.auth import get_verified_user +from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.memory import ( clean_memory_content, clean_memory_path, @@ -124,6 +125,61 @@ def _memory_metadata(memory: MemoryModel) -> dict: } +async def reindex_memory_vectors_for_user( + request: Request, + user_id: str, + memories: list[MemoryModel] | None = None, + user=None, +) -> int: + collection_name = f'user-memory-{user_id}' + try: + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name) + except Exception as e: + log.debug(e) + + memories = memories if memories is not None else await Memories.get_memories_by_user_id(user_id) + memories = memories or [] + if not memories: + return 0 + + vectors = await asyncio.gather( + *[ + request.app.state.EMBEDDING_FUNCTION( + memory_vector_text(memory.content, memory.path), + prefix=RAG_EMBEDDING_CONTENT_PREFIX, + user=user, + ) + for memory in memories + ] + ) + + await ASYNC_VECTOR_DB_CLIENT.upsert( + collection_name=collection_name, + items=[ + { + 'id': memory.id, + 'text': memory_vector_text(memory.content, memory.path), + 'vector': vectors[idx], + 'metadata': _memory_metadata(memory), + } + for idx, memory in enumerate(memories) + ], + ) + return len(memories) + + +async def upsert_memory_vectors_or_reindex(request: Request, user, items: list[dict]) -> None: + try: + await ASYNC_VECTOR_DB_CLIENT.upsert(collection_name=f'user-memory-{user.id}', items=items) + except Exception as e: + message = str(e).lower() + if 'dimension' not in message or 'embedding' not in message: + raise + + log.warning('Memory vector dimension mismatch for user %s; reindexing memory vectors.', user.id) + await reindex_memory_vectors_for_user(request, user.id, user=user) + + @router.post('/add', response_model=MemoryModel | None) async def add_memory( request: Request, @@ -152,9 +208,10 @@ async def add_memory( memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user ) - await ASYNC_VECTOR_DB_CLIENT.upsert( - collection_name=f'user-memory-{user.id}', - items=[ + await upsert_memory_vectors_or_reindex( + request, + user, + [ { 'id': memory.id, 'text': memory_vector_text(memory.content, memory.path), @@ -226,7 +283,7 @@ async def update_memories( response.append(result) if upsert_items: - await ASYNC_VECTOR_DB_CLIENT.upsert(collection_name=f'user-memory-{user.id}', items=upsert_items) + await upsert_memory_vectors_or_reindex(request, user, upsert_items) if delete_ids: await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=delete_ids) @@ -386,8 +443,42 @@ async def read_memory_path( ############################ -# ResetMemoryFromVectorDB +# ReindexMemoryVectorDB ############################ +@router.post('/reindex') +async def reindex_memories_from_vector_db( + request: Request, + user=Depends(get_admin_user), +): + memories = await Memories.get_memories() + memories = memories or [] + memories_by_user_id = {} + for memory in memories: + memories_by_user_id.setdefault(memory.user_id, []).append(memory) + + users_result = await Users.get_users() + users = users_result.get('users', []) if users_result else [] + total_memories = 0 + + for memory_user in users: + total_memories += await reindex_memory_vectors_for_user( + request, + memory_user.id, + memories=memories_by_user_id.get(memory_user.id, []), + user=memory_user, + ) + + await publish_event( + request, + EVENTS.MEMORY_RESET, + actor=user, + subject_id='all', + subject_type='user', + data={'count': total_memories, 'user_count': len(users), 'reindex': True}, + ) + return {'status': True, 'total_users': len(users), 'total_memories': total_memories} + + @router.post('/reset', response_model=bool) async def reset_memory_from_vector_db( request: Request, @@ -403,32 +494,7 @@ async def reset_memory_from_vector_db( """ await check_memories_permission(user) - await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') - - memories = await Memories.get_memories_by_user_id(user.id) - - # Generate vectors in parallel - vectors = await asyncio.gather( - *[ - request.app.state.EMBEDDING_FUNCTION( - memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user - ) - for memory in memories - ] - ) - - await ASYNC_VECTOR_DB_CLIENT.upsert( - collection_name=f'user-memory-{user.id}', - items=[ - { - 'id': memory.id, - 'text': memory_vector_text(memory.content, memory.path), - 'vector': vectors[idx], - 'metadata': _memory_metadata(memory), - } - for idx, memory in enumerate(memories) - ], - ) + count = await reindex_memory_vectors_for_user(request, user.id, user=user) await publish_event( request, @@ -436,7 +502,7 @@ async def reset_memory_from_vector_db( actor=user, subject_id=user.id, subject_type='user', - data={'count': len(memories)}, + data={'count': count, 'reindex': True}, ) return True @@ -512,9 +578,10 @@ async def update_memory_by_id( memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user ) - await ASYNC_VECTOR_DB_CLIENT.upsert( - collection_name=f'user-memory-{user.id}', - items=[ + await upsert_memory_vectors_or_reindex( + request, + user, + [ { 'id': memory.id, 'text': memory_vector_text(memory.content, memory.path), diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index e2137d6654..a0fd97705d 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import base64 import io -import json import logging import posixpath from typing import Optional @@ -20,8 +19,8 @@ from fastapi import ( from fastapi.responses import RedirectResponse, StreamingResponse from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.constants import ERROR_MESSAGES -from open_webui.events import EVENTS, publish_event from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES +from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config @@ -41,6 +40,7 @@ from open_webui.utils.access_control import filter_allowed_access_grants, has_pe from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.chat_variables import get_chat_variables_schema +from open_webui.utils.models import get_all_models from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -262,6 +262,15 @@ async def create_new_model( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + if not is_valid_model_id(form_data.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.MODEL_ID_TOO_LONG, + ) + if form_data.base_model_id == form_data.id: + # Should never be stored: a model cannot be based on itself. + form_data.base_model_id = None + model = await Models.get_model_by_id(form_data.id, db=db) if model: raise HTTPException( @@ -269,43 +278,58 @@ async def create_new_model( detail=ERROR_MESSAGES.MODEL_ID_TAKEN, ) - if not is_valid_model_id(form_data.id): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.MODEL_ID_TOO_LONG, - ) - - else: - await _verify_knowledge_file_access( - getattr(form_data.meta, 'knowledge', None) if form_data.meta else None, - user, - db, - ) - - form_data.access_grants = await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - form_data.access_grants, - 'sharing.public_models', - ) - - model = await Models.insert_new_model(form_data, user.id, db=db) - if model: - await publish_event( - request, - EVENTS.MODEL_CREATED, - actor=user, - subject_id=model.id, - data={'name': model.name}, - ) - return model - else: + if user.role != 'admin': + if not form_data.base_model_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.DEFAULT(), + detail=ERROR_MESSAGES.UNAUTHORIZED, ) + if not request.app.state.MODELS: + await get_all_models(request, user=user) + for base_model in request.app.state.MODELS.values(): + base_model_id = base_model.get('id') + if base_model.get('preset') or not base_model_id: + continue + + if form_data.id == base_model_id or ( + base_model.get('owned_by') == 'ollama' and form_data.id == base_model_id.split(':', 1)[0] + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.MODEL_ID_TAKEN, + ) + + await _verify_knowledge_file_access( + getattr(form_data.meta, 'knowledge', None) if form_data.meta else None, + user, + db, + ) + + form_data.access_grants = await filter_allowed_access_grants( + await Config.get('user.permissions'), + user.id, + user.role, + form_data.access_grants, + 'sharing.public_models', + ) + + model = await Models.insert_new_model(form_data, user.id, db=db) + if not model: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.DEFAULT(), + ) + + await publish_event( + request, + EVENTS.MODEL_CREATED, + actor=user, + subject_id=model.id, + data={'name': model.name}, + ) + return model + ############################ # ExportModels @@ -332,7 +356,7 @@ async def export_models( if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: return await Models.get_models(db=db) else: - return await Models.get_models_by_user_id(user.id, db=db) + return await Models.get_models(writable_by_user_id=user.id, db=db) ############################ @@ -391,12 +415,16 @@ async def import_models( else: writable_model_ids = set(existing_model_ids) + base_model_ids = None imported_ids = [] for model_data in data: model_id = model_data.get('id') if model_id and is_valid_model_id(model_id): - imported_ids.append(model_id) + if model_data.get('base_model_id') == model_id: + # Should never be stored: heal bad exports/API payloads. + model_data['base_model_id'] = None + # Defense-in-depth: skip models referencing inaccessible files try: await _verify_knowledge_file_access( @@ -427,6 +455,18 @@ async def import_models( ) continue + if ( + user.role != 'admin' + and existing_model.base_model_id + and not model_data.get('base_model_id', existing_model.base_model_id) + ): + log.warning( + 'import_models: user %s skipped model %s (cannot clear base model)', + user.id, + model_id, + ) + continue + # Update existing model model_data['meta'] = { **existing_model.meta.model_dump(), @@ -452,6 +492,37 @@ async def import_models( model_data['meta'] = model_data.get('meta', {}) model_data['params'] = model_data.get('params', {}) new_model = ModelForm(**model_data) + + if user.role != 'admin': + if not new_model.base_model_id: + log.warning( + 'import_models: user %s skipped model %s (no base model set)', + user.id, + model_id, + ) + continue + + if base_model_ids is None: + base_model_ids = set() + if not request.app.state.MODELS: + await get_all_models(request, user=user) + for base_model in request.app.state.MODELS.values(): + base_model_id = base_model.get('id') + if base_model.get('preset') or not base_model_id: + continue + + base_model_ids.add(base_model_id) + if base_model.get('owned_by') == 'ollama': + base_model_ids.add(base_model_id.split(':', 1)[0]) + + if model_id in base_model_ids: + log.warning( + 'import_models: user %s skipped model %s (id belongs to a base model)', + user.id, + model_id, + ) + continue + new_model.access_grants = await filter_allowed_access_grants( await Config.get('user.permissions'), user.id, @@ -460,6 +531,8 @@ async def import_models( 'sharing.public_models', ) await Models.insert_new_model(user_id=user.id, form_data=new_model, db=db) + + imported_ids.append(model_id) await publish_event( request, EVENTS.MODEL_IMPORTED, @@ -609,6 +682,9 @@ async def get_model_profile_image( # only serve known-safe raster types inline; reject SVG/unknown (can run script on our origin) if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES: + # LICENSE covers this Open WebUI fallback logo. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. return RedirectResponse( url='/static/favicon.png', status_code=status.HTTP_302_FOUND, @@ -636,6 +712,9 @@ async def get_model_profile_image( status_code=status.HTTP_302_FOUND, ) + # LICENSE covers this Open WebUI fallback logo. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. return RedirectResponse( url='/static/favicon.png', status_code=status.HTTP_302_FOUND, @@ -737,6 +816,15 @@ async def update_model_by_id( if 'base_model_id' not in form_data.model_fields_set: form_data.base_model_id = model.base_model_id + if form_data.base_model_id == form_data.id: + # Should never be stored: a model cannot be based on itself. + form_data.base_model_id = None + + if user.role != 'admin' and model.base_model_id and not form_data.base_model_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) if 'profile_image_url' not in form_data.meta.model_fields_set: form_data.meta.profile_image_url = model.meta.profile_image_url diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index ceb47708f5..c1951d2097 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -1,4 +1,3 @@ -import json import logging from typing import Optional from uuid import uuid4 @@ -340,11 +339,10 @@ async def get_note_chat_by_id( chat = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db) if chat: log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id) - payload = {**(chat.chat or {})} - params = {**(payload.get('params') or {})} + params = {**((chat.chat or {}).get('params') or {})} changed = False - - if params.pop('note_id', None) is not None: + if 'note_id' in params: + del params['note_id'] changed = True system = ( @@ -357,12 +355,8 @@ async def get_note_chat_by_id( params['system'] = system changed = True - if payload.pop('system', None) is not None: - changed = True - - payload['params'] = params if changed: - updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) + updated_chat = await Chats.update_chat_by_id(chat.id, {'params': params}, db=db, touch=False) if updated_chat: return updated_chat @@ -435,11 +429,10 @@ async def get_note_chats_by_id( chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db) normalized_chats = [] for chat in chats: - payload = {**(chat.chat or {})} - params = {**(payload.get('params') or {})} + params = {**((chat.chat or {}).get('params') or {})} changed = False - - if params.pop('note_id', None) is not None: + if 'note_id' in params: + del params['note_id'] changed = True system = ( @@ -452,12 +445,8 @@ async def get_note_chats_by_id( params['system'] = system changed = True - if payload.pop('system', None) is not None: - changed = True - - payload['params'] = params if changed: - chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) or chat + chat = await Chats.update_chat_by_id(chat.id, {'params': params}, db=db, touch=False) or chat normalized_chats.append(chat) diff --git a/backend/open_webui/routers/notifications.py b/backend/open_webui/routers/notifications.py index c3ae3ff3b7..6cc098392f 100644 --- a/backend/open_webui/routers/notifications.py +++ b/backend/open_webui/routers/notifications.py @@ -94,6 +94,9 @@ async def set_default_notification_target(target_id: str, user=Depends(get_verif async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)): await _check_notifications_access(user) try: + # LICENSE covers this Open WebUI notification identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI') return await test_target(user.id, target_id, app_name) except ValueError as e: diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index b91aed2ac4..00f41c01b3 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import json import logging import os import random @@ -16,12 +15,8 @@ import aiohttp from aiocache import cached from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from fastapi.responses import StreamingResponse -from pydantic import BaseModel, ConfigDict, validator -from sqlalchemy.ext.asyncio import AsyncSession - from open_webui.config import UPLOAD_DIR from open_webui.constants import ERROR_MESSAGES -from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, @@ -31,6 +26,7 @@ from open_webui.env import ( FORWARD_SESSION_INFO_HEADER_CHAT_ID, MODELS_CACHE_TTL, ) +from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config @@ -40,15 +36,17 @@ from open_webui.models.users import UserModel from open_webui.utils.access_control import check_model_access from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.headers import get_custom_headers, include_user_info_headers -from open_webui.utils.model_ids import strip_provider_model_prefix from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import calculate_sha256 +from open_webui.utils.model_ids import strip_provider_model_prefix from open_webui.utils.payload import ( apply_model_params_to_body_ollama, apply_model_params_to_body_openai, apply_system_prompt_to_body, ) from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper +from pydantic import BaseModel, ConfigDict, validator +from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -429,6 +427,8 @@ async def get_all_models(request: Request, user: UserModel | None = None): for m in response.get('models', []): if prefix_id: m['model'] = f'{prefix_id}.{m["model"]}' + if m.get('name'): + m['name'] = f'{prefix_id}.{m["name"]}' if allowed_tags: m['tags'] = allowed_tags if connection_type: @@ -445,7 +445,7 @@ async def get_all_models(request: Request, user: UserModel | None = None): dt = datetime.fromisoformat(expires_map[m['model']]) m['expires_at'] = int(dt.timestamp()) except Exception as exc: - log.debug(f'Failed to get loaded models: {exc}') + log.debug('Failed to get loaded models: %s', exc) request.app.state.OLLAMA_MODELS = {m['model']: m for m in models_dict['models']} return models_dict @@ -473,7 +473,7 @@ async def get_filtered_models(models, user, db=None): @router.get('/api/tags') -@router.get('/api/tags/{url_idx}') +@router.get('/api/tags/{url_idx}', dependencies=[Depends(get_admin_user)]) async def get_ollama_tags( request: Request, url_idx: int | None = None, @@ -536,7 +536,7 @@ async def get_ollama_loaded_models( @router.get('/api/version') -@router.get('/api/version/{url_idx}') +@router.get('/api/version/{url_idx}', dependencies=[Depends(get_admin_user)]) async def get_ollama_versions( request: Request, user=Depends(get_verified_user), @@ -619,7 +619,7 @@ async def unload_model( try: res = await send_request( f'{url}/api/generate', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=key, user=user, ) @@ -652,12 +652,12 @@ async def pull_model( form_data['model'] = form_data.get('model', form_data.get('name')) url = (await Config.get('ollama.base_urls', []))[url_idx] - log.info(f'url: {url}') + log.info('url: %s', url) # Admins may pull from any registry return await send_request( f'{url}/api/pull', - payload=json.dumps({**form_data, 'insecure': True}), + payload=JSONCodec.dumps({**form_data, 'insecure': True}), key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=True, @@ -693,7 +693,7 @@ async def push_model( url_idx = models[form_data.model]['urls'][0] url = (await Config.get('ollama.base_urls', []))[url_idx] - log.debug(f'url: {url}') + log.debug('url: %s', url) return await send_request( f'{url}/api/push', @@ -725,7 +725,7 @@ async def create_model( if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) - log.debug(f'form_data: {form_data}') + log.debug('form_data: %s', form_data) url = (await Config.get('ollama.base_urls', []))[url_idx] return await send_request( @@ -812,7 +812,7 @@ async def delete_model( await send_request( f'{url}/api/delete', 'DELETE', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=key, user=user, ) @@ -854,7 +854,7 @@ async def show_model_info( return await send_request( f'{url}/api/show', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=key, user=user, ) @@ -883,7 +883,7 @@ async def embed( if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) - log.info(f'generate_ollama_batch_embeddings {form_data}') + log.info('generate_ollama_batch_embeddings %s', form_data) await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) await validate_ollama_backend_idx(request, form_data.model, url_idx, user) @@ -934,7 +934,7 @@ async def embeddings( if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) - log.info(f'generate_ollama_embeddings {form_data}') + log.info('generate_ollama_embeddings %s', form_data) await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) await validate_ollama_backend_idx(request, form_data.model, url_idx, user) @@ -1055,7 +1055,7 @@ class GenerateChatCompletionForm(BaseModel): async def validate_ollama_backend_idx(request: Request, model: str, url_idx: int | None, user) -> None: # A caller-supplied url_idx must point to a backend the model is actually # served from; the None path is already constrained to that allow-list. - if url_idx is None or user is None or getattr(user, 'role', None) == 'admin' or BYPASS_MODEL_ACCESS_CONTROL: + if url_idx is None or user is None or getattr(user, 'role', None) == 'admin': return models = request.app.state.OLLAMA_MODELS if not models or model not in models: @@ -1149,7 +1149,7 @@ async def generate_chat_completion( return await send_request( f'{url}/api/chat', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=get_api_key(url_idx, url, api_configs), user=user, stream=form_data.stream, @@ -1244,7 +1244,7 @@ async def generate_openai_completion( return await send_request( f'{url}/v1/completions', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=get_api_key(url_idx, url, api_configs), user=user, stream=payload.get('stream', False), @@ -1295,7 +1295,7 @@ async def generate_openai_embeddings( return await send_request( f'{url}/v1/embeddings', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, metadata=metadata, @@ -1352,7 +1352,7 @@ async def generate_openai_chat_completion( return await send_request( f'{url}/v1/chat/completions', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=get_api_key(url_idx, url, api_configs), user=user, stream=payload.get('stream', False), @@ -1404,7 +1404,7 @@ async def generate_anthropic_messages( return await send_request( f'{url}/v1/messages', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=get_api_key(url_idx, url, api_configs), user=user, stream=payload.get('stream', False), @@ -1462,7 +1462,7 @@ async def generate_responses( return await send_request( f'{url}/v1/responses', - payload=json.dumps(payload), + payload=JSONCodec.dumps(payload), key=get_api_key(url_idx, url, api_configs), user=user, stream=payload.get('stream', False), @@ -1474,7 +1474,7 @@ async def generate_responses( @router.get('/v1/models') -@router.get('/v1/models/{url_idx}') +@router.get('/v1/models/{url_idx}', dependencies=[Depends(get_admin_user)]) async def get_openai_models( request: Request, url_idx: int | None = None, @@ -1581,7 +1581,7 @@ async def download_file_stream( ) as blob_resp: if blob_resp.ok: await asyncio.to_thread(os.remove, file_path) - yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n' + yield f'data: {JSONCodec.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n' else: raise RuntimeError('Ollama: Could not create blob, Please try again.') @@ -1639,11 +1639,11 @@ async def upload_model( async def file_process_stream(): nonlocal ollama_url total_size = os.path.getsize(file_path) - log.info(f'Total Model Size: {total_size}') + log.info('Total Model Size: %s', total_size) # Stage 2: hash the file and emit SSE progress file_hash = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) - log.info(f'Model Hash: {file_hash}') + log.info('Model Hash: %s', file_hash) try: bytes_read = 0 @@ -1651,7 +1651,7 @@ async def upload_model( while chunk := await f.read(chunk_size): bytes_read += len(chunk) progress = round(bytes_read / total_size * 100, 2) - event = json.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read}) + event = JSONCodec.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read}) yield f'data: {event}\n\n' session = await get_session() @@ -1677,24 +1677,24 @@ async def upload_model( # Stage 4: create the model model, _ext = os.path.splitext(filename) - log.info(f'Created Model: {model}') + log.info('Created Model: %s', model) create_payload = { 'model': model, 'files': {filename: f'sha256:{file_hash}'}, } - log.info(f'Model Payload: {create_payload}') + log.info('Model Payload: %s', create_payload) async with session.post( f'{ollama_url}/api/create', headers={'Content-Type': 'application/json'}, - data=json.dumps(create_payload), + data=JSONCodec.dumps(create_payload), ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=get_client_timeout(), ) as create_resp: if create_resp.ok: log.info('API SUCCESS!') - event = json.dumps( + event = JSONCodec.dumps( {'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model} ) yield f'data: {event}\n\n' @@ -1703,6 +1703,6 @@ async def upload_model( raise Exception(f'Failed to create model in Ollama. {resp_text}') except Exception as exc: - yield f'data: {json.dumps({"error": str(exc)})}\n\n' + yield f'data: {JSONCodec.dumps({"error": str(exc)})}\n\n' return StreamingResponse(file_process_stream(), media_type='text/event-stream') diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index d5cad77bef..0f21defdd5 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import hashlib -import json import logging import re from typing import Optional @@ -23,7 +22,6 @@ from open_webui.config import ( CACHE_DIR, ) from open_webui.constants import ERROR_MESSAGES -from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, @@ -33,6 +31,7 @@ from open_webui.env import ( FORWARD_SESSION_INFO_HEADER_CHAT_ID, MODELS_CACHE_TTL, ) +from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config @@ -40,15 +39,12 @@ from open_webui.models.groups import Groups from open_webui.models.models import Models from open_webui.models.users import UserModel from open_webui.utils.access_control import check_model_access, has_connection_access, has_permission -from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url +from open_webui.utils.anthropic import ANTHROPIC_VERSION, get_anthropic_models, is_anthropic_url from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.headers import get_custom_headers, include_user_info_headers from open_webui.utils.json_codec import JSONCodec +from open_webui.utils.misc import convert_logit_bias_input_to_json from open_webui.utils.model_ids import strip_provider_model_prefix -from open_webui.utils.misc import ( - convert_logit_bias_input_to_json, - stream_chunks_handler, -) from open_webui.utils.payload import ( apply_model_params_to_body_openai, apply_system_prompt_to_body, @@ -166,6 +162,9 @@ async def get_headers_and_cookies( 'Content-Type': 'application/json', **( { + # LICENSE covers this Open WebUI upstream metadata identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'HTTP-Referer': 'https://openwebui.com/', 'X-Title': 'Open WebUI', } @@ -244,9 +243,48 @@ router = APIRouter() LLAMACPP_LOADED_STATES = {'loaded', 'sleeping'} LLAMACPP_UNLOADED_STATES = {'loading', 'unloaded'} +MODEL_MANAGEMENT_ENDPOINTS = { + 'llama.cpp': { + 'list': '/models', + 'download': '/models', + 'delete': '/models', + 'load': '/models/load', + 'unload': '/models/unload', + 'sse': '/models/sse', + }, + 'lmstudio': { + 'list': '/api/v1/models', + 'download': '/api/v1/models/download', + 'download_status': '/api/v1/models/download/status/{job_id}', + 'load': '/api/v1/models/load', + 'unload': '/api/v1/models/unload', + }, +} -def get_llamacpp_model_loaded_state(model: dict, provider: str, manual_model_ids: bool = False) -> bool | None: +def get_model_management_root_url(url: str, provider: str) -> str: + root_url = url.rstrip('/') + if provider in ('llama.cpp', 'lmstudio'): + for suffix in ('/api/v1', '/api/v0', '/v1'): + if root_url.endswith(suffix): + return root_url.removesuffix(suffix) + + return root_url + + +def get_provider_model_loaded_state(model: dict, provider: str, manual_model_ids: bool = False) -> bool | None: + if provider == 'lmstudio': + if model.get('loaded_instances'): + return True + + state = model.get('state') + if state == 'loaded': + return True + if state == 'not-loaded': + return False + + return None + if provider != 'llama.cpp': return None @@ -305,8 +343,113 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]: return url, key, api_config -async def get_anthropic_token_count_target(request: Request, form_data: dict, user: UserModel): - """Resolve the upstream LiteLLM connection for an Anthropic token-count request.""" +async def clear_openai_model_cache(request: Request): + await get_all_models.cache.clear() + request.app.state.BASE_MODELS = [] + request.app.state.OPENAI_MODELS = {} + models = getattr(request.app.state, 'MODELS', None) + if hasattr(models, 'clear'): + models.clear() + else: + request.app.state.MODELS = {} + + +async def get_model_management_connection(url_idx: int) -> tuple[str, str, dict, str]: + if not await Config.get('openai.enable'): + raise HTTPException(status_code=503, detail='OpenAI API is disabled') + + try: + url, key, api_config = await get_openai_connection(url_idx) + except IndexError: + raise HTTPException(status_code=404, detail='Connection not found') + + provider = api_config.get('provider', '') + if provider not in MODEL_MANAGEMENT_ENDPOINTS: + raise HTTPException( + status_code=400, + detail=f'Provider "{provider or "default"}" does not support model management', + ) + + return get_model_management_root_url(url, provider), key, api_config, provider + + +def get_model_management_path(provider: str, operation: str, path_params: dict | None = None) -> str: + try: + path = MODEL_MANAGEMENT_ENDPOINTS[provider][operation] + except KeyError: + raise HTTPException(status_code=400, detail=f'Provider "{provider}" does not support {operation}') + + return path.format(**(path_params or {})) + + +def get_model_management_payload(provider: str, operation: str, payload: dict | None) -> dict | None: + if provider == 'lmstudio' and operation == 'unload' and payload: + return {'instance_id': payload.get('instance_id') or payload.get('model')} + + return payload + + +async def send_model_management_request( + request: Request, + url_idx: int, + operation: str, + method: str = 'GET', + payload: dict | None = None, + query: dict | None = None, + path_params: dict | None = None, + stream: bool = False, + user: UserModel | None = None, +): + root_url, key, api_config, provider = await get_model_management_connection(url_idx) + path = get_model_management_path(provider, operation, path_params=path_params) + payload = get_model_management_payload(provider, operation, payload) + headers, cookies = await get_headers_and_cookies(request, root_url, key, api_config, user=user) + + response = None + streaming = False + try: + session = await get_session() + response = await session.request( + method, + f'{root_url}{path}', + json=payload, + params=query, + headers=headers, + cookies=cookies, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=get_client_timeout(stream=stream), + ) + + if not response.ok: + try: + error = await response.json(loads=JSONCodec.loads) + except Exception: + error = await response.text() + raise HTTPException(status_code=response.status, detail=error) + + if stream: + streaming = True + return StreamingResponse( + stream_wrapper(response, passthrough=True), + status_code=response.status, + headers=_clean_proxy_headers(response.headers), + ) + + try: + return await response.json(loads=JSONCodec.loads) + except Exception: + return {'success': True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=response.status if response else 500, detail=str(e)) + finally: + if not streaming: + await cleanup_response(response) + + +async def get_anthropic_request_target(request: Request, form_data: dict, user: UserModel): + """Resolve the upstream connection, payload and auth headers for a native Anthropic request.""" requested_model = form_data.get('model') if not requested_model: raise HTTPException(status_code=400, detail='model is required') @@ -334,14 +477,20 @@ async def get_anthropic_token_count_target(request: Request, form_data: dict, us payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) + + # Anthropic's native endpoints reject bearer auth, the key belongs in x-api-key. + if is_anthropic_url(url): + headers.setdefault('anthropic-version', ANTHROPIC_VERSION) + if api_config.get('auth_type') in (None, 'bearer'): + headers.pop('Authorization', None) + headers.setdefault('x-api-key', key) + return requested_model, payload, url, key, headers, cookies async def count_anthropic_tokens(request: Request, form_data: dict, user: UserModel) -> int: """Forward an Anthropic token-count request through an OpenAI-compatible connection.""" - requested_model, payload, url, key, headers, cookies = await get_anthropic_token_count_target( - request, form_data, user - ) + requested_model, payload, url, key, headers, cookies = await get_anthropic_request_target(request, form_data, user) request_url = f'{url.rstrip("/")}/messages/count_tokens' response = None @@ -350,7 +499,7 @@ async def count_anthropic_tokens(request: Request, form_data: dict, user: UserMo response = await session.request( method='POST', url=request_url, - data=json.dumps(payload), + data=JSONCodec.dumps(payload), headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -499,7 +648,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): await f.write(chunk) async with aiofiles.open(file_body_path, 'w') as f: - await f.write(json.dumps(json.loads(body.decode('utf-8')))) + await f.write(JSONCodec.dumps(JSONCodec.loads(body.decode('utf-8')))) # Return the saved file return FileResponse(file_path) @@ -516,6 +665,9 @@ async def speech(request: Request, user=Depends(get_verified_user)): except Exception: detail = f'External: {e}' + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. raise HTTPException( status_code=r.status if r else 500, detail=detail if detail else 'Open WebUI: Server Connection Error', @@ -598,6 +750,8 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: if prefix_id: model['id'] = f'{prefix_id}.{model.get("id", model.get("name", ""))}' + if model.get('name'): + model['name'] = f'{prefix_id}.{model["name"]}' if tags: model['tags'] = tags @@ -608,7 +762,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: if provider: model['provider'] = provider - log.debug(f'get_all_models:responses() {responses}') + log.debug('get_all_models:responses() %s', responses) return responses @@ -664,7 +818,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: return not any(name in model_id for name in _UNSUPPORTED_OPENAI_MODEL_KEYWORDS) def get_merged_models(model_lists): - log.debug(f'merge_models_lists {model_lists}') + log.debug('merge_models_lists %s', model_lists) models = {} for idx, model_list in enumerate(model_lists): @@ -692,7 +846,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: 'urlIdx': idx, } - loaded = get_llamacpp_model_loaded_state( + loaded = get_provider_model_loaded_state( model, provider, manual_model_ids=bool(api_config.get('model_ids')), @@ -705,14 +859,14 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: return models models = get_merged_models(map(extract_data, responses)) - log.debug(f'models: {models}') + log.debug('models: %s', models) request.app.state.OPENAI_MODELS = models return {'data': list(models.values())} @router.get('/models') -@router.get('/models/{url_idx}') +@router.get('/models/{url_idx}', dependencies=[Depends(get_admin_user)]) async def get_models(request: Request, url_idx: int | None = None, user=Depends(get_verified_user)): if not await Config.get('openai.enable'): raise HTTPException(status_code=503, detail='OpenAI API is disabled') @@ -773,6 +927,9 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends( except aiohttp.ClientError as e: # ClientError covers all aiohttp requests issues log.exception(f'Client error: {str(e)}') + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') except Exception as e: log.exception(f'Unexpected error: {e}') @@ -785,6 +942,121 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends( return models +class ProviderModelOperationForm(BaseModel): + model: str + model_config = ConfigDict(extra='allow') + + +@router.get('/models/{url_idx}/catalog') +async def get_provider_model_catalog(request: Request, url_idx: int, user=Depends(get_admin_user)): + return await send_model_management_request(request, url_idx, 'list', user=user) + + +@router.post('/models/{url_idx}/download') +async def download_provider_model( + request: Request, + url_idx: int, + form_data: ProviderModelOperationForm, + user=Depends(get_admin_user), +): + root_url, _, api_config, provider = await get_model_management_connection(url_idx) + payload = form_data.model_dump(exclude_none=True) + payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id')) + + result = await send_model_management_request(request, url_idx, 'download', 'POST', payload, user=user) + await clear_openai_model_cache(request) + await publish_event( + request, + EVENTS.MODEL_PROVIDER_MODEL_CREATED, + actor=user, + subject_id=payload['model'], + data={'provider': provider, 'url_idx': url_idx, 'base_url': root_url}, + ) + return result + + +@router.get('/models/{url_idx}/download/status/{job_id}') +async def get_provider_model_download_status( + request: Request, + url_idx: int, + job_id: str, + user=Depends(get_admin_user), +): + return await send_model_management_request( + request, + url_idx, + 'download_status', + path_params={'job_id': job_id}, + user=user, + ) + + +@router.post('/models/{url_idx}/load') +async def load_provider_model( + request: Request, + url_idx: int, + form_data: ProviderModelOperationForm, + user=Depends(get_admin_user), +): + _, _, api_config, _ = await get_model_management_connection(url_idx) + payload = form_data.model_dump(exclude_none=True) + payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id')) + + result = await send_model_management_request(request, url_idx, 'load', 'POST', payload, user=user) + await clear_openai_model_cache(request) + return result + + +@router.post('/models/{url_idx}/unload') +async def unload_provider_model( + request: Request, + url_idx: int, + form_data: ProviderModelOperationForm, + user=Depends(get_admin_user), +): + _, _, api_config, _ = await get_model_management_connection(url_idx) + payload = form_data.model_dump(exclude_none=True) + payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id')) + + result = await send_model_management_request(request, url_idx, 'unload', 'POST', payload, user=user) + await clear_openai_model_cache(request) + return result + + +@router.get('/models/{url_idx}/sse') +async def stream_provider_model_events(request: Request, url_idx: int, user=Depends(get_admin_user)): + return await send_model_management_request(request, url_idx, 'sse', stream=True, user=user) + + +@router.delete('/models/{url_idx}') +async def delete_provider_model( + request: Request, + url_idx: int, + model: str, + user=Depends(get_admin_user), +): + root_url, _, api_config, provider = await get_model_management_connection(url_idx) + actual_model = strip_provider_model_prefix(model, api_config.get('prefix_id')) + + result = await send_model_management_request( + request, + url_idx, + 'delete', + 'DELETE', + query={'model': actual_model}, + user=user, + ) + await clear_openai_model_cache(request) + await publish_event( + request, + EVENTS.MODEL_PROVIDER_MODEL_DELETED, + actor=user, + subject_id=actual_model, + data={'provider': provider, 'url_idx': url_idx, 'base_url': root_url}, + ) + return result + + class ConnectionVerificationForm(BaseModel): url: str key: str @@ -915,7 +1187,7 @@ def get_azure_allowed_params(api_version: str) -> set[str]: if api_version >= '2024-09-01-preview': allowed_params.add('stream_options') except ValueError: - log.debug(f'Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters.') + log.debug('Invalid API version %s for Azure OpenAI. Defaulting to allowed parameters.', api_version) return allowed_params @@ -963,7 +1235,7 @@ def convert_to_azure_payload(url, payload: dict, api_version: str): # Remove temperature if not 1 for o-series models if 'temperature' in payload and payload['temperature'] != 1: log.debug( - f'Removing temperature parameter for o-series model {model} as only default value (1) is supported' + 'Removing temperature parameter for o-series model %s as only default value (1) is supported', model ) del payload['temperature'] @@ -1084,8 +1356,21 @@ def convert_to_responses_payload(payload: dict) -> dict: content_parts.append({'type': text_type, 'text': part.get('text', '')}) elif part.get('type') == 'image_url': url_data = part.get('image_url', {}) - url = url_data.get('url', '') if isinstance(url_data, dict) else url_data - content_parts.append({'type': 'input_image', 'image_url': url}) + if isinstance(url_data, dict): + url = url_data.get('url', '') + detail = url_data.get('detail') or 'auto' + else: + url = url_data if isinstance(url_data, str) else '' + detail = 'auto' + content_parts.append({'type': 'input_image', 'image_url': url, 'detail': detail}) + elif part.get('type') == 'file': + # OpenAI-compatible proxy path only. Open WebUI attachments are handled + # separately via metadata.files/RAG and must not be converted here. + file = part.get('file') + if isinstance(file, dict): + file_part = {k: file[k] for k in ('file_id', 'file_data', 'filename') if k in file} + if 'file_id' in file_part or 'file_data' in file_part: + content_parts.append({'type': 'input_file', **file_part}) else: content_parts = [{'type': text_type, 'text': str(content)}] @@ -1277,7 +1562,7 @@ async def generate_chat_completion( logit_bias = convert_logit_bias_input_to_json(payload['logit_bias']) if logit_bias: - payload['logit_bias'] = json.loads(logit_bias) + payload['logit_bias'] = JSONCodec.loads(logit_bias) headers, cookies = await get_headers_and_cookies(request, url, key, api_config, metadata, user=user) @@ -1329,7 +1614,7 @@ async def generate_chat_completion( if not is_streaming_request: payload.pop('stream_options', None) - payload = json.dumps(payload) + payload = JSONCodec.dumps(payload) r = None streaming = False @@ -1361,7 +1646,7 @@ async def generate_chat_completion( error_body[:1000], ) try: - error_json = json.loads(error_body) + error_json = JSONCodec.loads(error_body) await publish_model_provider_request_failed( request, actor=user, @@ -1373,7 +1658,7 @@ async def generate_chat_completion( upstream_error=error_json, ) return JSONResponse(status_code=r.status, content=error_json) - except json.JSONDecodeError: + except JSONCodec.JSONDecodeError: await publish_model_provider_request_failed( request, actor=user, @@ -1391,7 +1676,7 @@ async def generate_chat_completion( streaming = True return StreamingResponse( - stream_wrapper(r, content_handler=stream_chunks_handler), + stream_wrapper(r), status_code=r.status, headers=_clean_proxy_headers(r.headers), ) @@ -1449,7 +1734,7 @@ async def embeddings(request: Request, form_data: dict, user): """ idx = 0 # Prepare payload/body - body = json.dumps(form_data) + body = JSONCodec.dumps(form_data) # Find correct backend url/key based on model model_id = form_data.get('model') # Check if model is already in app state cache to avoid expensive get_all_models() call @@ -1580,8 +1865,6 @@ async def responses( # Enforce per-model access control await check_model_access(user, await Models.get_model_by_id(model_id), BYPASS_MODEL_ACCESS_CONTROL) - body = json.dumps(payload) - if model_id: models = request.app.state.OPENAI_MODELS if not models or model_id not in models: @@ -1592,6 +1875,9 @@ async def responses( url, key, api_config = await get_openai_connection(idx) + payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id')) + body = JSONCodec.dumps(payload) + r = None streaming = False @@ -1690,8 +1976,8 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): payload = None if body: try: - payload = json.loads(body) - except (json.JSONDecodeError, ValueError): + payload = JSONCodec.loads(body) + except (JSONCodec.JSONDecodeError, ValueError): payload = None is_streaming_request = bool(payload.get('stream', False)) if isinstance(payload, dict) else False @@ -1729,9 +2015,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): api_version = api_config.get('api_version', '2023-03-15-preview') headers['api-version'] = api_version - payload = json.loads(body) + payload = JSONCodec.loads(body) url, payload = convert_to_azure_payload(url, payload, api_version) - body = json.dumps(payload).encode() + body = JSONCodec.dumps(payload).encode() request_url = f'{url}/{path}?api-version={api_version}' else: @@ -1784,6 +2070,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): raise except Exception as e: log.exception(e) + # LICENSE covers this Open WebUI error identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. raise HTTPException( status_code=r.status if r else 500, detail='Open WebUI: Server Connection Error', diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index bffc7dd012..827fadf7c9 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -198,7 +198,7 @@ router = APIRouter() @router.get('/list') async def get_pipelines_list(request: Request, user=Depends(get_admin_user)): responses = await get_all_models_responses(request, user) - log.debug(f'get_pipelines_list: get_openai_models_responses returned {responses}') + log.debug('get_pipelines_list: get_openai_models_responses returned %s', responses) urlIdxs = [idx for idx, response in enumerate(responses) if response is not None and 'pipelines' in response] base_urls = await Config.get('openai.api_base_urls', []) @@ -221,7 +221,7 @@ async def upload_pipeline( file: UploadFile = File(...), user=Depends(get_admin_user), ): - log.info(f'upload_pipeline: urlIdx={urlIdx}, filename={file.filename}') + log.info('upload_pipeline: urlIdx=%s, filename=%s', urlIdx, file.filename) filename = os.path.basename(file.filename) # Check if the uploaded file is a python file diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 4bb2fbca88..517565f12f 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -import json +import io import logging import mimetypes import os @@ -12,6 +12,7 @@ from datetime import datetime from pathlib import Path from types import SimpleNamespace from typing import Callable, Iterator, Optional, Sequence, Union +from urllib.parse import unquote, urlparse import tiktoken from fastapi import ( @@ -47,6 +48,8 @@ from open_webui.config import ( ) from open_webui.constants import ERROR_MESSAGES from open_webui.env import ( + AIOHTTP_CLIENT_ALLOW_REDIRECTS, + AIOHTTP_CLIENT_SESSION_SSL, DEVICE_TYPE, DOCKER, RAG_EMBEDDING_TIMEOUT, @@ -63,7 +66,7 @@ from open_webui.models.knowledge import Knowledges from open_webui.models.config import Config # Document loaders -from open_webui.retrieval.loaders.youtube import YoutubeLoader +from open_webui.retrieval.loaders.youtube import YoutubeLoader, YoutubeTranscriptError from open_webui.retrieval.utils import ( build_loader_from_config, get_loader_config, @@ -72,6 +75,7 @@ from open_webui.retrieval.utils import ( get_embedding_function, get_model_path, get_reranking_function, + is_youtube_url, query_collection, query_collection_with_hybrid_search, query_doc, @@ -92,6 +96,7 @@ from open_webui.retrieval.web.firecrawl import search_firecrawl from open_webui.retrieval.web.google_pse import search_google_pse from open_webui.retrieval.web.jina_search import search_jina from open_webui.retrieval.web.kagi import search_kagi +from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url # Web search engines from open_webui.retrieval.web.main import SearchResult @@ -231,7 +236,7 @@ def get_rf( eos = getattr(cfg, 'eos_token_id', None) if eos is not None: cfg.pad_token_id = eos - log.debug(f'Missing pad_token_id detected; set to eos_token_id={eos}') + log.debug('Missing pad_token_id detected; set to eos_token_id=%s', eos) else: log.warning('Neither pad_token_id nor eos_token_id present in model config') except Exception as e2: @@ -382,6 +387,7 @@ RETRIEVAL_CONFIG_KEYS = { 'TAVILY_EXTRACT_DEPTH': 'web.search.tavily_extract_depth', 'TEXT_SPLITTER': 'rag.text_splitter', 'TIKA_SERVER_URL': 'rag.tika_server_url', + 'TIKA_SERVER_VERSION': 'rag.tika_server_version', 'TIKTOKEN_ENCODING_NAME': 'rag.tiktoken_encoding_name', 'TOP_K': 'rag.top_k', 'TOP_K_RERANKER': 'rag.top_k_reranker', @@ -444,6 +450,16 @@ class ProcessUrlForm(CollectionNameForm): url: str +class ProcessUrlResponse(BaseModel): + status: bool + type: str + name: str + url: str + collection_name: str | None = None + content: str | None = None + file: dict | None = None + + class SearchForm(BaseModel): queries: list[str] @@ -475,19 +491,19 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)): class OpenAIConfigForm(BaseModel): - url: str - key: str + url: str | None = None + key: str | None = None class OllamaConfigForm(BaseModel): - url: str - key: str + url: str | None = None + key: str | None = None class AzureOpenAIConfigForm(BaseModel): - url: str - key: str - version: str + url: str | None = None + key: str | None = None + version: str | None = None class EmbeddingModelUpdateForm(BaseModel): @@ -520,7 +536,7 @@ async def unload_embedding_model(request: Request): @router.post('/embedding/update') async def update_embedding_config(request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user)): config = await get_retrieval_config() - log.info(f'Updating embedding model: {config.RAG_EMBEDDING_MODEL} to {form_data.RAG_EMBEDDING_MODEL}') + log.info('Updating embedding model: %s to %s', config.RAG_EMBEDDING_MODEL, form_data.RAG_EMBEDDING_MODEL) await unload_embedding_model(request) try: config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE @@ -529,23 +545,18 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS - if config.RAG_EMBEDDING_ENGINE in [ - 'ollama', - 'openai', - 'azure_openai', - ]: - if form_data.openai_config is not None: - config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url - config.RAG_OPENAI_API_KEY = form_data.openai_config.key + if config.RAG_EMBEDDING_ENGINE == 'openai' and form_data.openai_config is not None: + config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url or '' + config.RAG_OPENAI_API_KEY = form_data.openai_config.key or '' - if form_data.ollama_config is not None: - config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url - config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key + if config.RAG_EMBEDDING_ENGINE == 'ollama' and form_data.ollama_config is not None: + config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url or '' + config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key or '' - if form_data.azure_openai_config is not None: - config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url - config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key - config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version + if config.RAG_EMBEDDING_ENGINE == 'azure_openai' and form_data.azure_openai_config is not None: + config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url or '' + config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key or '' + config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version or '' request.app.state.ef = get_ef( config.RAG_EMBEDDING_ENGINE, @@ -649,6 +660,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): 'EXTERNAL_DOCUMENT_LOADER_API_KEY': config.EXTERNAL_DOCUMENT_LOADER_API_KEY, 'EXTERNAL_DOCUMENT_LOADER_HEADERS': config.EXTERNAL_DOCUMENT_LOADER_HEADERS, 'TIKA_SERVER_URL': config.TIKA_SERVER_URL, + 'TIKA_SERVER_VERSION': config.TIKA_SERVER_VERSION, 'DOCLING_SERVER_URL': config.DOCLING_SERVER_URL, 'DOCLING_API_KEY': config.DOCLING_API_KEY, 'DOCLING_PARAMS': config.DOCLING_PARAMS, @@ -884,6 +896,7 @@ class ConfigForm(BaseModel): EXTERNAL_DOCUMENT_LOADER_HEADERS: dict | None = None TIKA_SERVER_URL: str | None = None + TIKA_SERVER_VERSION: str | None = None DOCLING_SERVER_URL: str | None = None DOCLING_API_KEY: str | None = None DOCLING_PARAMS: dict | None = None @@ -1060,6 +1073,9 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend config.TIKA_SERVER_URL = ( form_data.TIKA_SERVER_URL if form_data.TIKA_SERVER_URL is not None else config.TIKA_SERVER_URL ) + config.TIKA_SERVER_VERSION = ( + form_data.TIKA_SERVER_VERSION if form_data.TIKA_SERVER_VERSION is not None else config.TIKA_SERVER_VERSION + ) config.DOCLING_SERVER_URL = ( form_data.DOCLING_SERVER_URL if form_data.DOCLING_SERVER_URL is not None else config.DOCLING_SERVER_URL ) @@ -1160,7 +1176,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend else config.RAG_RERANKING_BATCH_SIZE ) - log.info(f'Updating reranking model: {config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}') + log.info('Updating reranking model: %s to %s', config.RAG_RERANKING_MODEL, form_data.RAG_RERANKING_MODEL) try: config.RAG_RERANKING_MODEL = ( form_data.RAG_RERANKING_MODEL if form_data.RAG_RERANKING_MODEL is not None else config.RAG_RERANKING_MODEL @@ -1358,6 +1374,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'EXTERNAL_DOCUMENT_LOADER_API_KEY': config.EXTERNAL_DOCUMENT_LOADER_API_KEY, 'EXTERNAL_DOCUMENT_LOADER_HEADERS': config.EXTERNAL_DOCUMENT_LOADER_HEADERS, 'TIKA_SERVER_URL': config.TIKA_SERVER_URL, + 'TIKA_SERVER_VERSION': config.TIKA_SERVER_VERSION, 'DOCLING_SERVER_URL': config.DOCLING_SERVER_URL, 'DOCLING_API_KEY': config.DOCLING_API_KEY, 'DOCLING_PARAMS': config.DOCLING_PARAMS, @@ -1644,7 +1661,7 @@ def save_docs_to_vector_db( return ', '.join(docs_info) - log.debug(f'save_docs_to_vector_db: document {_get_docs_info(docs)} {collection_name}') + log.debug('save_docs_to_vector_db: document %s %s', _get_docs_info(docs), collection_name) # Check if entries with the same hash (metadata.hash) already exist if metadata and 'hash' in metadata: @@ -1664,7 +1681,7 @@ def save_docs_to_vector_db( existing_file_id = result.metadatas[0][0].get('file_id') if existing_file_id != metadata.get('file_id'): - log.info(f'Document with hash {metadata["hash"]} already exists') + log.info('Document with hash %s already exists', metadata['hash']) raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT) if split: @@ -1707,7 +1724,7 @@ def save_docs_to_vector_db( ) docs = text_splitter.split_documents(docs) elif config.TEXT_SPLITTER == 'token': - log.info(f'Using token text splitter: {config.TIKTOKEN_ENCODING_NAME}') + log.info('Using token text splitter: %s', config.TIKTOKEN_ENCODING_NAME) tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME)) text_splitter = TokenTextSplitter( @@ -1749,16 +1766,16 @@ def save_docs_to_vector_db( try: if VECTOR_DB_CLIENT.has_collection(collection_name=collection_name): - log.info(f'collection {collection_name} already exists') + log.info('collection %s already exists', collection_name) if overwrite: VECTOR_DB_CLIENT.delete_collection(collection_name=collection_name) - log.info(f'deleting existing collection {collection_name}') + log.info('deleting existing collection %s', collection_name) elif add is False: - log.info(f'collection {collection_name} already exists, overwrite is False and add is False') + log.info('collection %s already exists, overwrite is False and add is False', collection_name) return True - log.info(f'generating embeddings for {collection_name}') + log.info('generating embeddings for %s', collection_name) embedding_function = get_embedding_function( config.RAG_EMBEDDING_ENGINE, config.RAG_EMBEDDING_MODEL, @@ -1802,7 +1819,7 @@ def save_docs_to_vector_db( request.app.state.main_loop, ) embeddings = future.result(timeout=embedding_timeout) - log.info(f'embeddings generated {len(embeddings)} for {len(texts)} items') + log.info('embeddings generated %s for %s items', len(embeddings), len(texts)) items = [ { @@ -1814,13 +1831,13 @@ def save_docs_to_vector_db( for idx, text in enumerate(texts) ] - log.info(f'adding to collection {collection_name}') + log.info('adding to collection %s', collection_name) VECTOR_DB_CLIENT.insert( collection_name=collection_name, items=items, ) - log.info(f'added {len(items)} items to collection {collection_name}') + log.info('added %s items to collection %s', len(items), collection_name) return True except Exception as e: log.exception(e) @@ -1833,6 +1850,10 @@ class ProcessFileForm(BaseModel): collection_name: str | None = None +def has_vector_results(result) -> bool: + return bool(result and result.ids and result.ids[0]) + + @router.post('/process/file') async def process_file( request: Request, @@ -1842,7 +1863,6 @@ async def process_file( ): """ Process a file and save its content to the vector database. - Process a file and save its content to the vector database. Note: granular session management is used to prevent connection pool exhaustion. The session is committed before external API calls, and updates use a fresh session. """ @@ -1855,11 +1875,13 @@ async def process_file( if file: try: collection_name = form_data.collection_name + file_collection_name = f'file-{file.id}' if collection_name is None: - collection_name = f'file-{file.id}' + collection_name = file_collection_name else: await _validate_collection_access([collection_name], user, access_type='write') + collection_names = [collection_name] if form_data.content: # Update the content in the file @@ -1867,7 +1889,7 @@ async def process_file( try: # /files/{file_id}/data/content/update - await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}') + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection_name) except Exception: # Audio file upload pipeline pass @@ -1887,25 +1909,30 @@ async def process_file( text_content = form_data.content elif form_data.collection_name: - # Check if the file has already been processed and save the content + # Add this file to a knowledge collection. # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update + # Reuse file-{id} chunks when they exist; otherwise restore file-{id} + # from stored file content while adding the file to the knowledge collection. - result = await ASYNC_VECTOR_DB_CLIENT.query( - collection_name=f'file-{file.id}', filter={'file_id': file.id} + file_result = await ASYNC_VECTOR_DB_CLIENT.query( + collection_name=file_collection_name, filter={'file_id': file.id} ) + stored_content = (file.data or {}).get('content') - if result is not None and len(result.ids[0]) > 0: + if has_vector_results(file_result): + # Normal path: reuse the already-processed per-file chunks. docs = [ Document( - page_content=result.documents[0][idx], - metadata=result.metadatas[0][idx], + page_content=file_result.documents[0][idx], + metadata=file_result.metadatas[0][idx], ) - for idx, id in enumerate(result.ids[0]) + for idx, id in enumerate(file_result.ids[0]) ] - else: + elif stored_content is not None: + # Repair path: vector chunks are missing, but SQL still has the file text. docs = [ Document( - page_content=file.data.get('content', ''), + page_content=stored_content, metadata={ **file.meta, 'name': file.filename, @@ -1915,8 +1942,11 @@ async def process_file( }, ) ] + collection_names.append(file_collection_name) + else: + raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) - text_content = file.data.get('content', '') + text_content = stored_content or '' else: # Process the file and save the content # Usage: /files/ @@ -1970,7 +2000,7 @@ async def process_file( hash = calculate_sha256_string(text_content) if config.BYPASS_EMBEDDING_AND_RETRIEVAL: - await Files.update_file_data_by_id(file.id, {'status': 'completed'}, db=db) + await Files.update_file_data_by_id(file.id, {'status': 'completed', 'error': None}, db=db) await Files.update_file_hash_by_id(file.id, hash, db=db) await publish_event( request, @@ -1998,21 +2028,23 @@ async def process_file( # calls asyncio.run_coroutine_threadsafe(..., main_loop).result() # which blocks the calling thread. We MUST run it in a # worker thread to avoid deadlocking the event loop. - result = await run_in_threadpool( - save_docs_to_vector_db, - request, - docs=docs, - collection_name=collection_name, - config=config, - metadata={ - 'file_id': file.id, - 'name': file.filename, - 'hash': hash, - }, - add=(True if form_data.collection_name else False), - user=user, - ) - log.info(f'added {len(docs)} items to collection {collection_name}') + result = True + for name in collection_names: + result = await run_in_threadpool( + save_docs_to_vector_db, + request, + docs=docs, + collection_name=name, + config=config, + metadata={ + 'file_id': file.id, + 'name': file.filename, + 'hash': hash, + }, + add=(True if form_data.collection_name else False), + user=user, + ) + log.info('added %s items to collection %s', len(docs), collection_name) if result: # Fresh session for the final update. @@ -2027,7 +2059,7 @@ async def process_file( await Files.update_file_data_by_id( file.id, - {'status': 'completed'}, + {'status': 'completed', 'error': None}, db=session, ) await Files.update_file_hash_by_id(file.id, hash, db=session) @@ -2057,12 +2089,25 @@ async def process_file( async with get_async_db() as session: await Files.update_file_data_by_id( file.id, - {'status': 'failed'}, + {'status': 'failed', 'error': str(e)}, db=session, ) # Clear the hash so the file can be re-uploaded after fixing the issue await Files.update_file_hash_by_id(file.id, None, db=session) + await publish_event( + request, + EVENTS.RETRIEVAL_CONTENT_PROCESS_FAILED, + actor=user, + subject_id=file.id, + subject_type='file', + data={ + 'collection_name': collection_name, + 'filename': file.filename, + 'message': f'{file.filename}: {e}', + }, + ) + if 'No pandoc was found' in str(e): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -2128,6 +2173,190 @@ async def process_text( ) +async def _fetch_url(url: str, max_size_mb: int | str | None) -> dict: + await asyncio.to_thread(validate_url, url) + max_bytes = None + if max_size_mb: + try: + max_bytes = int(max_size_mb) * 1024 * 1024 + except (TypeError, ValueError): + max_bytes = None + + async with get_ssrf_safe_session() as session: + async with session.get( + url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS + ) as response: + response.raise_for_status() + + content_type = response.headers.get('Content-Type', '') + content_disposition = response.headers.get('Content-Disposition', '') + content_length = response.headers.get('Content-Length') + base_content_type = content_type.split(';')[0].strip().lower() + is_attachment = content_disposition.split(';')[0].strip().lower() == 'attachment' + + chunks = [] + total = 0 + + iterator = response.content.iter_chunked(64 * 1024) + first_chunk = await anext(iterator, b'') + + if not is_attachment and base_content_type in {'text/html', 'application/xhtml+xml'}: + return {'kind': 'web'} + + if not is_attachment and base_content_type in {'', 'application/octet-stream', 'binary/octet-stream'}: + sample = first_chunk[:4096].lstrip().lower() + if ( + sample.startswith((b' max_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size_mb} MB'), + ) + except ValueError: + pass + + if first_chunk: + chunks.append(first_chunk) + total += len(first_chunk) + if max_bytes and total > max_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size_mb} MB'), + ) + + async for chunk in iterator: + if not chunk: + continue + chunks.append(chunk) + total += len(chunk) + if max_bytes and total > max_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size_mb} MB'), + ) + + data = b''.join(chunks) + + image_mime = None + try: + from PIL import Image + + image = Image.open(io.BytesIO(data)) + image.verify() + image_mime = Image.MIME.get(image.format) if image.format else None + except Exception: + image_mime = None + + if base_content_type.startswith('image/') and image_mime is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('Invalid image content'), + ) + + filename = '' + filename_star = re.search(r"filename\*=UTF-8''([^;]+)", content_disposition, re.IGNORECASE) + filename_plain = re.search(r'filename="?([^";]+)"?', content_disposition, re.IGNORECASE) + if filename_star: + filename = unquote(filename_star.group(1)) + elif filename_plain: + filename = filename_plain.group(1) + if not filename: + filename = os.path.basename(urlparse(url).path) + filename = os.path.basename(filename or 'download') + + resolved_content_type = ( + image_mime or base_content_type or mimetypes.guess_type(filename)[0] or 'application/octet-stream' + ) + if not os.path.splitext(filename)[1]: + filename = f'{filename}{mimetypes.guess_extension(resolved_content_type) or ".bin"}' + + return { + 'kind': 'file', + 'data': data, + 'filename': filename, + 'content_type': resolved_content_type, + } + + +@router.post('/process/url', response_model=ProcessUrlResponse) +async def process_url( + request: Request, + form_data: ProcessUrlForm, + process: bool = Query(True, description='Whether to process and save the content'), + user=Depends(get_verified_user), +): + try: + if is_youtube_url(form_data.url): + result = await process_web(request, form_data, process=process, user=user) + return { + 'status': True, + 'type': 'youtube', + 'name': form_data.url, + 'url': form_data.url, + 'collection_name': result.get('collection_name'), + 'content': result.get('content'), + } + + config = await get_retrieval_config() + url_result = await _fetch_url(form_data.url, config.FILE_MAX_SIZE) + + if url_result['kind'] == 'web': + result = await process_web(request, form_data, process=process, user=user) + return { + 'status': True, + 'type': 'web', + 'name': form_data.url, + 'url': form_data.url, + 'collection_name': result.get('collection_name'), + 'content': result.get('content'), + } + + from open_webui.routers.files import upload_file_handler + + is_image = url_result['content_type'].startswith('image/') + file = UploadFile( + file=io.BytesIO(url_result['data']), + filename=url_result['filename'], + headers={'content-type': url_result['content_type']}, + ) + uploaded_file = await upload_file_handler( + request, + file=file, + metadata={'source_url': form_data.url}, + process=process and not is_image, + process_in_background=False, + user=user, + ) + file_data = uploaded_file.model_dump() if hasattr(uploaded_file, 'model_dump') else uploaded_file + file_id = file_data.get('id') if isinstance(file_data, dict) else None + if file_id: + refreshed_file = await Files.get_file_by_id(file_id) + if refreshed_file: + file_data = refreshed_file.model_dump() + return { + 'status': True, + 'type': 'image' if is_image else 'file', + 'name': url_result['filename'], + 'url': form_data.url, + 'collection_name': (file_data.get('meta') or {}).get('collection_name'), + 'file': file_data, + } + except HTTPException: + raise + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e, 'Error processing URL'), + ) + + @router.post('/process/youtube') @router.post('/process/web') async def process_web( @@ -2138,8 +2367,25 @@ async def process_web( user=Depends(get_verified_user), ): config = await get_retrieval_config() + try: content, docs = await get_content_from_url(request, form_data.url) + except HTTPException: + raise + except YoutubeTranscriptError as e: + log.warning('YouTube transcript unavailable for %s: %s', form_data.url, e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e, f'Could not read content from {form_data.url}'), + ) + + try: log.debug('text_content: %s', content) if process: @@ -2167,6 +2413,7 @@ async def process_web( 'status': True, 'collection_name': collection_name, 'filename': form_data.url, + 'content': content, 'file': { 'data': { 'content': content, @@ -2568,7 +2815,7 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen result_items = [] try: - logging.debug(f'trying to web search with {config.WEB_SEARCH_ENGINE, form_data.queries}') + logging.debug('trying to web search with %s', (config.WEB_SEARCH_ENGINE, form_data.queries)) # Use semaphore to limit concurrent requests based on WEB_SEARCH_CONCURRENT_REQUESTS # 0 or None = unlimited (previous behavior), positive number = limited concurrency @@ -2611,11 +2858,14 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen urls.append(item.link) urls = list(dict.fromkeys(urls)) - log.debug(f'urls: {urls}') + log.debug('urls: %s', urls) except Exception as e: log.exception('Web search failed') - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e)) + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e, ERROR_MESSAGES.WEB_SEARCH_ERROR), + ) if len(urls) == 0: raise HTTPException( @@ -2710,7 +2960,7 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen log.exception('Web search content loading failed') raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(e, ERROR_MESSAGES.WEB_SEARCH_ERROR()), + detail=ERROR_MESSAGES.DEFAULT(e, ERROR_MESSAGES.WEB_SEARCH_ERROR), ) diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index 42d24a4a53..a9bb999cb7 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -258,7 +258,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) # Check if SCIM is enabled enable_scim = getattr(request.app.state, 'ENABLE_SCIM', False) - log.info(f'SCIM auth check - raw ENABLE_SCIM: {enable_scim}, type: {type(enable_scim)}') + log.info('SCIM auth check - raw ENABLE_SCIM: %s, type: %s', enable_scim, type(enable_scim)) if not enable_scim: raise HTTPException( @@ -268,7 +268,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) # Verify the SCIM token scim_token = getattr(request.app.state, 'SCIM_TOKEN', None) - log.debug(f'SCIM token configured: {bool(scim_token)}') + log.debug('SCIM token configured: %s', bool(scim_token)) if not scim_token or not hmac.compare_digest(token, scim_token): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -517,20 +517,30 @@ async def get_users( # Simple filter parsing - supports userName eq, externalId eq if 'userName eq' in filter: email = filter.split('"')[1] - user = await Users.get_user_by_email(email, db=db) - users_list = [user] if user else [] - total = 1 if user else 0 + response = await Users.get_scim_users(filter={'email': email}, limit=1, db=db) + users_list = response['users'] + total = response['total'] elif 'externalId eq' in filter: external_id = filter.split('"')[1] user = await find_user_by_external_id(external_id, db=db) users_list = [user] if user else [] total = 1 if user else 0 else: - response = await Users.get_users(skip=skip, limit=limit, db=db) + response = await Users.get_scim_users( + sort={'order_by': 'created_at'}, + skip=skip, + limit=limit, + db=db, + ) users_list = response['users'] total = response['total'] else: - response = await Users.get_users(skip=skip, limit=limit, db=db) + response = await Users.get_scim_users( + sort={'order_by': 'created_at'}, + skip=skip, + limit=limit, + db=db, + ) users_list = response['users'] total = response['total'] @@ -553,7 +563,7 @@ async def get_user( db: AsyncSession = Depends(get_async_session), ): """Get SCIM User by ID""" - user = await Users.get_user_by_id(user_id, db=db) + user = await Users.get_scim_user_by_id(user_id, db=db) if not user: return scim_error(status_code=status.HTTP_404_NOT_FOUND, detail=f'User {user_id} not found') @@ -624,11 +634,12 @@ async def create_user( detail='Failed to create user', ) - # Store externalId in the scim field - if user_data.externalId: - provider = get_scim_provider() - await Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) - new_user = await Users.get_user_by_id(user_id, db=db) + new_user = await Users.update_user_scim_by_id(user_id, get_scim_provider(), user_data.externalId, db=db) + if not new_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to stamp SCIM user', + ) await publish_event( request, @@ -654,7 +665,7 @@ async def update_user( db: AsyncSession = Depends(get_async_session), ): """Update SCIM User (full update)""" - user = await Users.get_user_by_id(user_id, db=db) + user = await Users.get_scim_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -700,15 +711,27 @@ async def update_user( await Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) updated_user = await Users.get_user_by_id(user_id, db=db) - await publish_event( - request, - EVENTS.USER_UPDATED, - subject_id=user_id, - source='scim', - data={ - 'updated_fields': list(update_data.keys()) + (['externalId'] if user_data.externalId else []), - }, - ) + updated_fields = list(update_data.keys()) + (['externalId'] if user_data.externalId else []) + role_changed = updated_user.role != user.role + user_updated_fields = [field for field in updated_fields if field != 'role'] + + if user_updated_fields: + await publish_event( + request, + EVENTS.USER_UPDATED, + subject_id=user_id, + source='scim', + data={'updated_fields': user_updated_fields}, + ) + + if role_changed: + await publish_event( + request, + EVENTS.USER_ROLE_UPDATED, + subject_id=user_id, + source='scim', + data={'role': updated_user.role}, + ) return await user_to_scim(updated_user, request, db=db) @@ -722,7 +745,7 @@ async def patch_user( db: AsyncSession = Depends(get_async_session), ): """Update SCIM User (partial update)""" - user = await Users.get_user_by_id(user_id, db=db) + user = await Users.get_scim_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -764,13 +787,26 @@ async def patch_user( else: updated_user = user - await publish_event( - request, - EVENTS.USER_UPDATED, - subject_id=user_id, - source='scim', - data={'updated_fields': list(update_data.keys())}, - ) + role_changed = updated_user.role != user.role + user_updated_fields = [field for field in update_data.keys() if field != 'role'] + + if user_updated_fields: + await publish_event( + request, + EVENTS.USER_UPDATED, + subject_id=user_id, + source='scim', + data={'updated_fields': user_updated_fields}, + ) + + if role_changed: + await publish_event( + request, + EVENTS.USER_ROLE_UPDATED, + subject_id=user_id, + source='scim', + data={'role': updated_user.role}, + ) return await user_to_scim(updated_user, request, db=db) @@ -783,7 +819,7 @@ async def delete_user( db: AsyncSession = Depends(get_async_session), ): """Delete SCIM User""" - user = await Users.get_user_by_id(user_id, db=db) + user = await Users.get_scim_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 4fefa2cb7a..75831fe58d 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -1,4 +1,5 @@ import logging +import re from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -38,27 +39,18 @@ router = APIRouter() @router.get('/', response_model=list[SkillUserResponse]) async def get_skills( request: Request, + query: Optional[str] = None, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: skills = await Skills.get_skills(db=db) else: - user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} - all_skills = await Skills.get_skills(db=db) - skills = [ - skill - for skill in all_skills - if skill.user_id == user.id - or await AccessGrants.has_access( - user_id=user.id, - resource_type='skill', - resource_id=skill.id, - permission='read', - user_group_ids=user_group_ids, - db=db, - ) - ] + skills = await Skills.get_skills(db=db, user_id=user.id) + + if query: + q = query.casefold() + skills = [skill for skill in skills if q in (skill.name or '').casefold()] return skills @@ -93,30 +85,29 @@ async def get_skill_list( if direction: filter['direction'] = direction - if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL): - groups = await Groups.get_groups_by_member_id(user.id, db=db) - if groups: - filter['group_ids'] = [group.id for group in groups] + is_bypass_admin = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + if not is_bypass_admin: + filter['group_ids'] = user_group_ids filter['user_id'] = user.id result = await Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db) + writable_skill_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type='skill', + resource_ids=[skill.id for skill in result.items], + permission='write', + user_group_ids=user_group_ids, + db=db, + ) + return SkillAccessListResponse( items=[ SkillAccessResponse( **skill.model_dump(), - write_access=( - (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) - or user.id == skill.user_id - or await AccessGrants.has_access( - user_id=user.id, - resource_type='skill', - resource_id=skill.id, - permission='write', - db=db, - ) - ), + write_access=(is_bypass_admin or user.id == skill.user_id or skill.id in writable_skill_ids), ) for skill in result.items ], @@ -149,7 +140,7 @@ async def export_skills( if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: return await Skills.get_skills(db=db) else: - return await Skills.get_skills_by_user_id(user.id, 'read', db=db) + return await Skills.get_skills(db=db, user_id=user.id) ############################ @@ -175,6 +166,13 @@ async def create_new_skill( form_data.id = form_data.id.lower().replace(' ', '-') + # The id goes into /id/{id}/... paths, so anything outside the slug charset is unreachable once stored. + if not re.fullmatch(r'[a-z0-9_-]+', form_data.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('Invalid skill ID'), + ) + existing = await Skills.get_skill_by_id(form_data.id, db=db) if existing is not None: raise HTTPException( diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 4e377e2b78..1dd53d8ebe 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -20,6 +20,7 @@ from open_webui.models.config import Config from open_webui.routers.pipelines import process_pipeline_inlet_filter from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.chat import generate_chat_completion +from open_webui.utils.payload import apply_params_to_form_data from open_webui.utils.task import ( autocomplete_generation_template, emoji_generation_template, @@ -40,6 +41,7 @@ router = APIRouter() TASK_CONFIG_KEYS = { 'TASK_MODEL': 'task.model.default', 'TASK_MODEL_EXTERNAL': 'task.model.external', + 'TASK_MODEL_PARAMS': 'task.model.params', 'TITLE_GENERATION_PROMPT_TEMPLATE': 'task.title.prompt_template', 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': 'task.image.prompt_template', 'ENABLE_AUTOCOMPLETE_GENERATION': 'task.autocomplete.enable', @@ -68,6 +70,34 @@ def config_updates(data: dict, key_map: dict[str, str]) -> dict: return {key_map[field]: value for field, value in data.items() if field in key_map} +def apply_task_model_params(payload: dict, models: dict, task_model_id: str, params: dict | None = None) -> dict: + model = models.get(payload.get('model')) or models.get(task_model_id) + if not model or (not params and not payload.get('params')): + return payload + return apply_params_to_form_data(payload, model, params or None) + + +async def get_task_model_generation_config(default_model_id: str, models) -> tuple[str, dict]: + config = await Config.get_many( + 'task.model.default', + 'task.model.external', + 'task.model.params', + ) + params = config.get('task.model.params') or {} + if not isinstance(params, dict): + params = {} + + return ( + get_task_model_id( + default_model_id, + config.get('task.model.default'), + config.get('task.model.external'), + models, + ), + {key: value for key, value in params.items() if value is not None and value != ''}, + ) + + ################################## # # Task Endpoints @@ -83,6 +113,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)): class TaskConfigForm(BaseModel): TASK_MODEL: Optional[str] TASK_MODEL_EXTERNAL: Optional[str] + TASK_MODEL_PARAMS: dict | None = None ENABLE_TITLE_GENERATION: bool TITLE_GENERATION_PROMPT_TEMPLATE: str IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: str @@ -117,7 +148,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: @@ -135,16 +166,9 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - await Config.get('task.model.default'), - await Config.get('task.model.external'), - models, - ) + task_model_id, task_model_params = await get_task_model_generation_config(model_id, models) - log.debug(f'generating chat title using model {task_model_id} for user {user.email} ') + log.debug('generating chat title using model %s for user %s ', task_model_id, user.email) title_template = await Config.get('task.title.prompt_template') if title_template != '': @@ -153,20 +177,14 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE content = await title_generation_template(template, form_data['messages'], user) - - max_tokens = models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000) + task_model_params = task_model_params or { + 'max_tokens': models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000) + } payload = { 'model': task_model_id, 'messages': [{'role': 'user', 'content': content}], 'stream': False, - **( - {'max_tokens': max_tokens} - if models[task_model_id].get('owned_by') == 'ollama' - else { - 'max_completion_tokens': max_tokens, - } - ), 'metadata': { **(request.state.metadata if hasattr(request.state, 'metadata') else {}), 'task': str(TASKS.TITLE_GENERATION), @@ -181,6 +199,8 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver except Exception as e: raise e + payload = apply_task_model_params(payload, models, task_model_id, task_model_params) + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -201,7 +221,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: @@ -214,16 +234,9 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - await Config.get('task.model.default'), - await Config.get('task.model.external'), - models, - ) + task_model_id, task_model_params = await get_task_model_generation_config(model_id, models) - log.debug(f'generating chat title using model {task_model_id} for user {user.email} ') + log.debug('generating chat title using model %s for user %s ', task_model_id, user.email) follow_up_template = await Config.get('task.follow_up.prompt_template') if follow_up_template != '': @@ -251,6 +264,8 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge except Exception as e: raise e + payload = apply_task_model_params(payload, models, task_model_id, task_model_params) + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -271,7 +286,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: @@ -284,16 +299,9 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - await Config.get('task.model.default'), - await Config.get('task.model.external'), - models, - ) + task_model_id, task_model_params = await get_task_model_generation_config(model_id, models) - log.debug(f'generating chat tags using model {task_model_id} for user {user.email} ') + log.debug('generating chat tags using model %s for user %s ', task_model_id, user.email) tags_template = await Config.get('task.tags.prompt_template') if tags_template != '': @@ -321,6 +329,8 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get except Exception as e: raise e + payload = apply_task_model_params(payload, models, task_model_id, task_model_params) + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -335,7 +345,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get async def generate_image_prompt(request: Request, form_data: dict, user=Depends(get_verified_user)): if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: @@ -348,16 +358,9 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - await Config.get('task.model.default'), - await Config.get('task.model.external'), - models, - ) + task_model_id, task_model_params = await get_task_model_generation_config(model_id, models) - log.debug(f'generating image prompt using model {task_model_id} for user {user.email} ') + log.debug('generating image prompt using model %s for user %s ', task_model_id, user.email) image_prompt_template = await Config.get('task.image.prompt_template') if image_prompt_template != '': @@ -385,6 +388,8 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends( except Exception as e: raise e + payload = apply_task_model_params(payload, models, task_model_id, task_model_params) + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -412,12 +417,12 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v ) if getattr(request.state, 'cached_queries', None): - log.info(f'Reusing cached queries: {request.state.cached_queries}') + log.info('Reusing cached queries: %s', request.state.cached_queries) return request.state.cached_queries if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: @@ -430,16 +435,9 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - await Config.get('task.model.default'), - await Config.get('task.model.external'), - models, - ) + task_model_id, task_model_params = await get_task_model_generation_config(model_id, models) - log.debug(f'generating {type} queries using model {task_model_id} for user {user.email}') + log.debug('generating %s queries using model %s for user %s', type, task_model_id, user.email) query_template = await Config.get('task.query.prompt_template') if query_template.strip() != '': @@ -467,6 +465,8 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v except Exception as e: raise e + payload = apply_task_model_params(payload, models, task_model_id, task_model_params) + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -498,7 +498,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: @@ -511,16 +511,9 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - await Config.get('task.model.default'), - await Config.get('task.model.external'), - models, - ) + task_model_id, task_model_params = await get_task_model_generation_config(model_id, models) - log.debug(f'generating autocompletion using model {task_model_id} for user {user.email}') + log.debug('generating autocompletion using model %s for user %s', task_model_id, user.email) autocomplete_template = await Config.get('task.autocomplete.prompt_template') if autocomplete_template.strip() != '': @@ -548,6 +541,8 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend except Exception as e: raise e + payload = apply_task_model_params(payload, models, task_model_id, task_model_params) + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -562,7 +557,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend async def generate_emoji(request: Request, form_data: dict, user=Depends(get_verified_user)): if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: @@ -575,16 +570,9 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - await Config.get('task.model.default'), - await Config.get('task.model.external'), - models, - ) + task_model_id, _ = await get_task_model_generation_config(model_id, models) - log.debug(f'generating emoji using model {task_model_id} for user {user.email} ') + log.debug('generating emoji using model %s for user %s ', task_model_id, user.email) template = DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE @@ -594,13 +582,6 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver 'model': task_model_id, 'messages': [{'role': 'user', 'content': content}], 'stream': False, - **( - {'max_tokens': 4} - if models[task_model_id].get('owned_by') == 'ollama' - else { - 'max_completion_tokens': 4, - } - ), 'metadata': { **(request.state.metadata if hasattr(request.state, 'metadata') else {}), 'task': str(TASKS.EMOJI_GENERATION), @@ -615,6 +596,8 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver except Exception as e: raise e + payload = apply_task_model_params(payload, models, task_model_id, {'max_tokens': 4}) + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -628,7 +611,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver async def generate_moa_response(request: Request, form_data: dict, user=Depends(get_verified_user)): if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index b2f997cb38..a9b11f00f5 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -13,15 +13,26 @@ import aiohttp from fastapi import APIRouter, Depends, Request, Response, WebSocket from fastapi.responses import JSONResponse, StreamingResponse from open_webui.config import TERMINAL_PROXY_HEADERS -from open_webui.events import EVENTS, publish_event from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.events import EVENTS, publish_event from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.utils.access_control import has_connection_access from open_webui.utils.auth import get_verified_user -from open_webui.utils.terminals import get_terminal_server_url -from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token +from open_webui.utils.headers import bearer_auth_header, normalize_bearer_token +from open_webui.utils.json_codec import JSONCodec +from open_webui.utils.terminals import ( + TERMINAL_CONTEXT_HEADER, + get_terminal_server_url, + is_terminal_orchestrator, + terminal_context_available, + terminal_context_config, + terminal_context_id, + terminal_chat_uploads, + terminal_contexts, +) from starlette.background import BackgroundTask +from starlette.requests import ClientDisconnect log = logging.getLogger(__name__) @@ -77,6 +88,8 @@ async def list_terminal_servers(request: Request, user=Depends(get_verified_user 'id': connection.get('id', ''), 'url': connection.get('url', ''), 'name': connection.get('name', ''), + 'contexts': terminal_contexts(connection), + 'config': {'chat_uploads': terminal_chat_uploads(connection)}, } for connection in connections if connection.get('enabled', True) and await has_connection_access(user, connection, user_group_ids) @@ -125,6 +138,13 @@ async def proxy_terminal( session_id = request.headers.get('x-session-id') if session_id: headers['X-Session-Id'] = session_id + if not terminal_context_available(connection, 'chat'): + return JSONResponse({'error': 'Terminal server is not available in chats'}, status_code=403) + context_id = terminal_context_id(connection, {'chat_id': session_id}, 'chat') + if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id: + return JSONResponse({'error': 'A saved chat is required for this terminal'}, status_code=409) + if context_id: + headers[TERMINAL_CONTEXT_HEADER] = context_id cookies = {} auth_type = connection.get('auth_type', 'bearer') @@ -153,13 +173,14 @@ async def proxy_terminal( if content_type: headers['Content-Type'] = content_type - body = await request.body() session = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=300, connect=10), trust_env=True, ) try: + body = await request.body() + upstream_response = await session.request( method=request.method, url=target_url, @@ -200,6 +221,13 @@ async def proxy_terminal( return Response(content=response_body, status_code=status_code, headers=filtered_headers) + except ClientDisconnect: + await session.close() + return Response(status_code=499) + except (aiohttp.ClientConnectionError, TimeoutError) as error: + await session.close() + log.error('Terminal proxy error: %s', str(error) or type(error).__name__) + return JSONResponse({'error': f'Terminal proxy error: {error}'}, status_code=502) except Exception as error: await session.close() log.exception('Terminal proxy error: %s', error) @@ -217,26 +245,26 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): The client must send ``{"type": "auth", "token": ""}`` as its first message after connecting. - Returns ``(user, connection)`` on success, or ``None`` after closing *ws* - with an appropriate error code. + Returns ``(user, connection, chat_id, token)`` on success, or ``None`` after + closing *ws* with an appropriate error code. """ import asyncio - import json from open_webui.utils.auth import get_verified_user_by_token # First-message authentication try: raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) - payload = json.loads(raw) + payload = JSONCodec.loads(raw) if payload.get('type') != 'auth': await ws.close(code=4001, reason='Expected auth message') return None - user = await get_verified_user_by_token(payload.get('token', ''), getattr(ws.app.state, 'redis', None)) + token = payload.get('token', '') + user = await get_verified_user_by_token(token, getattr(ws.app.state, 'redis', None)) if user is None: await ws.close(code=4001, reason='Invalid token') return None - except (asyncio.TimeoutError, json.JSONDecodeError): + except (asyncio.TimeoutError, JSONCodec.JSONDecodeError): await ws.close(code=4001, reason='Auth timeout or invalid payload') return None except Exception: @@ -260,7 +288,11 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): await ws.close(code=4003, reason='Access denied') return None - return user, connection + chat_id = payload.get('chat_id', '') + if not terminal_context_available(connection, 'chat'): + await ws.close(code=4003, reason='Terminal server is not available in chats') + return None + return user, connection, chat_id if isinstance(chat_id, str) else '', token @router.websocket('/{server_id}/api/terminals/{session_id}') @@ -273,14 +305,14 @@ async def ws_terminal( Uses first-message auth: the client sends ``{"type": "auth", "token": ""}`` as its first message. The proxy validates the JWT, then connects to the - upstream terminal server and authenticates with the server's API key. + upstream terminal server using the configured terminal auth mode. """ await ws.accept() result = await _resolve_authenticated_connection(ws, server_id) if result is None: return - user, connection = result + user, connection, chat_id, token = result base_url = get_terminal_server_url(connection) if not base_url: @@ -293,6 +325,13 @@ async def ws_terminal( upstream_params = {} # For orchestrator-backed servers, pass user_id upstream_params['user_id'] = user.id + context_id = terminal_context_id(connection, {'chat_id': chat_id}, 'chat') + upstream_headers = {} + if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id: + await ws.close(code=4003, reason='A saved chat is required for this terminal') + return + if context_id: + upstream_headers[TERMINAL_CONTEXT_HEADER] = context_id import urllib.parse @@ -308,7 +347,11 @@ async def ws_terminal( opened = False session = aiohttp.ClientSession() try: - async with session.ws_connect(upstream_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as upstream: + async with session.ws_connect( + upstream_url, + headers=upstream_headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as upstream: import asyncio import json as _json @@ -317,6 +360,8 @@ async def ws_terminal( if auth_type == 'bearer': key = normalize_bearer_token(connection.get('key', '')) await upstream.send_str(_json.dumps({'type': 'auth', 'token': key})) + elif auth_type == 'session' and is_terminal_orchestrator(connection): + await upstream.send_str(_json.dumps({'type': 'auth', 'token': token})) await publish_event( app, diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index f14b89d6ad..2e25d28ef3 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -66,15 +66,25 @@ async def get_tool_module(request, tool_id, load_from_db=True): @router.get('/', response_model=list[ToolUserResponse]) async def get_tools( request: Request, + query: Optional[str] = None, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): tools = [] + bypass_access_control = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL + user_group_ids = ( + set() if bypass_access_control else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + ) # Local Tools if ENABLE_PLUGINS: tools_cache = get_tools_cache(request) - for tool in await Tools.get_tools(defer_content=True, db=db): + for tool in await Tools.get_tools( + defer_content=True, + db=db, + user_id=None if bypass_access_control else user.id, + user_group_ids=user_group_ids, + ): tool_module = tools_cache.get(tool.id) has_user_valves = ( hasattr(tool_module, 'UserValves') @@ -165,34 +175,25 @@ async def get_tools( ) ) - if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - # Admin can see all tools - return tools - else: - user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} - filtered_tools = [] - for tool in tools: - if tool.user_id == user.id: - filtered_tools.append(tool) - elif str(tool.id).startswith('server:'): - if await has_access( - user.id, - 'read', - server_access_grants.get(str(tool.id), []), - user_group_ids, - db=db, - ): - filtered_tools.append(tool) - elif await AccessGrants.has_access( - user_id=user.id, - resource_type='tool', - resource_id=tool.id, - permission='read', - user_group_ids=user_group_ids, + if not bypass_access_control: + tools = [ + tool + for tool in tools + if not str(tool.id).startswith('server:') + or await has_access( + user.id, + 'read', + server_access_grants.get(str(tool.id), []), + user_group_ids, db=db, - ): - filtered_tools.append(tool) - return filtered_tools + ) + ] + + if query: + q = query.casefold() + tools = [tool for tool in tools if q in (tool.name or '').casefold()] + + return tools ############################ @@ -205,17 +206,21 @@ async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depe if not ENABLE_PLUGINS: return [] - if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - tools = await Tools.get_tools(defer_content=True, db=db) - else: - tools = await Tools.get_tools_by_user_id(user.id, 'read', defer_content=True, db=db) - - user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + bypass_access_control = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL + user_group_ids = ( + set() if bypass_access_control else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + ) + tools = await Tools.get_tools( + defer_content=True, + db=db, + user_id=None if bypass_access_control else user.id, + user_group_ids=user_group_ids, + ) result = [] for tool in tools: has_write = ( - (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) + bypass_access_control or user.id == tool.user_id or any( g.permission == 'write' @@ -330,10 +335,11 @@ async def export_tools( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - return await Tools.get_tools(db=db) - else: - return await Tools.get_tools_by_user_id(user.id, 'read', db=db) + bypass_access_control = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL + return await Tools.get_tools( + db=db, + user_id=None if bypass_access_control else user.id, + ) ############################ diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 01ea3aa5e4..d736c038bc 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -36,12 +36,12 @@ from open_webui.models.access_grants import AccessGrants from open_webui.models.knowledge import Knowledges from open_webui.models.models import Models from open_webui.models.tools import Tools -from open_webui.socket.main import disconnect_user_sessions from open_webui.utils.access_control import get_permissions, has_permission from open_webui.utils.auth import ( get_admin_user, get_password_hash, get_verified_user, + revoke_user_tokens, validate_password, ) from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables @@ -53,6 +53,38 @@ log = logging.getLogger(__name__) router = APIRouter() +def merge_user_ui_settings(defaults: dict, settings: dict) -> dict: + merged = dict(defaults) + for key, value in settings.items(): + if value is None: + continue + + default_value = merged.get(key) + merged[key] = ( + merge_user_ui_settings(default_value, value) + if isinstance(default_value, dict) and isinstance(value, dict) + else value + ) + return merged + + +def strip_default_interface_settings(defaults: dict, settings: dict) -> dict: + stripped = {} + for key, value in settings.items(): + if value is None: + continue + + default_value = defaults.get(key) + if isinstance(default_value, dict) and isinstance(value, dict): + nested = strip_default_interface_settings(default_value, value) + if nested: + stripped[key] = nested + elif value != default_value: + stripped[key] = value + + return stripped + + ############################ # GetUsers # A house is only as strong as its care for the least of @@ -80,14 +112,14 @@ async def get_users( filter = {} if query: filter['query'] = query - if order_by: - filter['order_by'] = order_by - if direction: - filter['direction'] = direction - filter['direction'] = direction - - result = await Users.get_users(filter=filter, skip=skip, limit=limit, db=db) + result = await Users.get_users( + filter=filter, + sort={'order_by': order_by, 'direction': direction}, + skip=skip, + limit=limit, + db=db, + ) users = result['users'] total = result['total'] @@ -135,12 +167,14 @@ async def search_users( filter = {} if query: filter['query'] = query - if order_by: - filter['order_by'] = order_by - if direction: - filter['direction'] = direction - return await Users.get_users(filter=filter, skip=skip, limit=limit, db=db) + return await Users.get_users( + filter=filter, + sort={'order_by': order_by, 'direction': direction}, + skip=skip, + limit=limit, + db=db, + ) ############################ @@ -196,13 +230,14 @@ class SharingPermissions(BaseModel): prompts: bool = False public_prompts: bool = False tools: bool = False - public_tools: bool = True + public_tools: bool = False skills: bool = False public_skills: bool = False notes: bool = False - public_notes: bool = True + public_notes: bool = False folders: bool = False public_chats: bool = False + open_chats: bool = False public_calendars: bool = False @@ -437,10 +472,22 @@ async def get_default_user_permissions_defaults(user=Depends(get_admin_user)): @router.get('/user/settings', response_model=UserSettings | None) async def get_user_settings_by_session_user( - user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) + raw: bool = False, + user=Depends(get_verified_user), ): # user already fetched by get_verified_user — no need to refetch - return user.settings + if raw: + return user.settings + + default_interface_settings = await Config.get('ui.default_interface_settings') + if not isinstance(default_interface_settings, dict) or not default_interface_settings: + return user.settings + + user_settings = user.settings.model_dump() if isinstance(user.settings, UserSettings) else dict(user.settings or {}) + ui_settings = user_settings.get('ui') if isinstance(user_settings.get('ui'), dict) else {} + user_settings['ui'] = merge_user_ui_settings(default_interface_settings, ui_settings) + + return UserSettings.model_validate(user_settings) ############################ @@ -463,12 +510,12 @@ async def update_user_settings_by_session_user( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - updated_user_settings = form_data.model_dump() + updated_user_settings = form_data.model_dump(exclude_unset=True) ui_settings = updated_user_settings.get('ui') if ( user.role != 'admin' and ui_settings is not None - and 'toolServers' in ui_settings.keys() + and 'toolServers' in ui_settings and not await has_permission( user.id, 'features.direct_tool_servers', @@ -495,6 +542,11 @@ async def update_user_settings_by_session_user( if isinstance(ui_notifications, dict): ui_notifications.pop('webhook_url', None) + default_interface_settings = await Config.get('ui.default_interface_settings') + ui_settings = updated_user_settings.get('ui') + if isinstance(default_interface_settings, dict) and isinstance(ui_settings, dict): + updated_user_settings['ui'] = strip_default_interface_settings(default_interface_settings, ui_settings) + user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db) if user: await publish_event( @@ -520,7 +572,6 @@ async def update_user_settings_by_session_user( async def get_user_status_by_session_user( request: Request, user=Depends(get_verified_user), - db: AsyncSession = Depends(get_async_session), ): if not await Config.get('users.enable_status'): raise HTTPException( @@ -570,7 +621,7 @@ async def update_user_status_by_session_user( @router.get('/user/info', response_model=dict | None) -async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): +async def get_user_info_by_session_user(user=Depends(get_verified_user)): # user already fetched by get_verified_user — no need to refetch return user.info @@ -665,10 +716,9 @@ async def get_user_usage_by_session_user( period_end = end_date or now if start_date is not None: period_start = start_date - elif days is not None: - period_start = period_end - ((days - 1) * 86400) else: - period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400)) + days = days or 730 + period_start = period_end - ((days - 1) * 86400) if period_start > period_end: raise HTTPException( @@ -917,7 +967,8 @@ async def update_user_by_id( raise HTTPException(400, detail=str(e)) hashed = await get_password_hash(form_data.password) - await Auths.update_user_password_by_id(user_id, hashed, db=db) + if await Auths.update_user_password_by_id(user_id, hashed, db=db): + await revoke_user_tokens(request, user_id) # Build update dict from only the provided fields update_data = {} @@ -941,10 +992,19 @@ async def update_user_by_id( updated_user = user if updated_user: - # If the role changed, disconnect all socket sessions so stale - # privileges cached in SESSION_POOL are invalidated. - if updated_user.role != user.role: - await disconnect_user_sessions(user_id) + updated_fields = [field for field in update_data.keys() if field != 'role'] + role_changed = updated_user.role != user.role + + if updated_fields: + await publish_event( + request, + EVENTS.USER_UPDATED, + actor=session_user, + subject_id=user_id, + data={'updated_fields': updated_fields}, + ) + + if role_changed: await publish_event( request, EVENTS.USER_ROLE_UPDATED, @@ -952,14 +1012,7 @@ async def update_user_by_id( subject_id=user_id, data={'role': updated_user.role}, ) - else: - await publish_event( - request, - EVENTS.USER_UPDATED, - actor=session_user, - subject_id=user_id, - data={'updated_fields': list(update_data.keys())}, - ) + if form_data.password: await publish_event( request, @@ -1012,7 +1065,6 @@ async def delete_user_by_id( result = await Auths.delete_auth_by_id(user_id, db=db) if result: - await disconnect_user_sessions(user_id) await publish_event( request, EVENTS.USER_DELETED, diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 46cc5719bb..0998682dd8 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -16,6 +16,7 @@ from open_webui.env import ( GLOBAL_LOG_LEVEL, REDIS_KEY_PREFIX, WEBSOCKET_EVENT_CALLER_TIMEOUT, + WEBSOCKET_HEARTBEAT_INTERVAL, WEBSOCKET_MANAGER, WEBSOCKET_REDIS_CLUSTER, WEBSOCKET_REDIS_LOCK_TIMEOUT, @@ -102,7 +103,7 @@ else: # Timeout duration in seconds TIMEOUT_DURATION = 3 -SESSION_POOL_TIMEOUT = 120 # seconds without heartbeat before session is reaped +SESSION_POOL_TIMEOUT = max(WEBSOCKET_HEARTBEAT_INTERVAL * 4, 120) if WEBSOCKET_HEARTBEAT_INTERVAL is not None else 120 # Dictionary to maintain the user pool @@ -121,6 +122,7 @@ if WEBSOCKET_MANAGER == 'redis': redis_url=WEBSOCKET_REDIS_URL, redis_sentinels=ws_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, + cache_set_signature=True, ) SESSION_POOL = RedisDict( @@ -176,6 +178,7 @@ YDOC_MANAGER = YdocManager( async def periodic_session_pool_cleanup(): """Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance).""" retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT) + renew_interval = max(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, 0.5) while True: if not session_aquire_func(): log.debug('Session cleanup lock held by another node. Retrying.') @@ -197,53 +200,68 @@ async def periodic_session_pool_cleanup(): del SESSION_POOL[sid] except KeyError: pass - await asyncio.sleep(SESSION_POOL_TIMEOUT) + + next_cleanup_at = time.monotonic() + SESSION_POOL_TIMEOUT + lock_lost = False + while True: + sleep_for = min(renew_interval, next_cleanup_at - time.monotonic()) + if sleep_for <= 0: + break + await asyncio.sleep(sleep_for) + if not session_renew_func(): + log.warning('Unable to renew session cleanup lock. Retrying cleanup ownership.') + lock_lost = True + break + + if lock_lost: + break finally: session_release_func() async def periodic_usage_pool_cleanup(): - max_retries = 2 retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT) - for attempt in range(max_retries + 1): - if aquire_func(): - break - else: - if attempt < max_retries: - log.debug(f'Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s...') + while True: + try: + if not aquire_func(): + log.debug('Usage cleanup lock held by another node. Retrying.') await asyncio.sleep(retry_delay) - else: - log.warning('Failed to acquire cleanup lock after retries. Skipping cleanup.') - return + continue - log.debug('Running periodic_cleanup') - try: - while True: - if not renew_func(): - log.error('Unable to renew cleanup lock. Exiting usage pool cleanup.') - raise Exception('Unable to renew usage pool cleanup lock.') + try: + while True: + if not renew_func(): + log.warning('Unable to renew usage cleanup lock. Retrying cleanup ownership.') + break - now = int(time.time()) - for model_id, connections in list(USAGE_POOL.items()): - # Creating a list of sids to remove if they have timed out - expired_sids = [ - sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION - ] + now = int(time.time()) + for model_id, connections in list(USAGE_POOL.items()): + expired_sids = [ + sid + for sid, details in connections.items() + if now - details['updated_at'] > TIMEOUT_DURATION + ] - if connections and not expired_sids: - continue + if connections and not expired_sids: + continue - for sid in expired_sids: - del connections[sid] + for sid in expired_sids: + del connections[sid] - if not connections: - log.debug(f'Cleaning up model {model_id} from usage pool') - del USAGE_POOL[model_id] - else: - USAGE_POOL[model_id] = connections - await asyncio.sleep(TIMEOUT_DURATION) - finally: - release_func() + if not connections: + log.debug('Cleaning up model %s from usage pool', model_id) + try: + del USAGE_POOL[model_id] + except KeyError: + pass + else: + USAGE_POOL[model_id] = connections + await asyncio.sleep(TIMEOUT_DURATION) + finally: + release_func() + except Exception: + log.exception('Usage pool cleanup failed. Retrying.') + await asyncio.sleep(retry_delay) app = socketio.ASGIApp( @@ -271,6 +289,13 @@ def get_session_ids_from_room(room): return list(members) if members else [] +def get_session_ids_by_user_id(user_id: str) -> list[str]: + """Get known session IDs for a user across the local rooms and shared session pool.""" + session_ids = set(get_session_ids_from_room(f'user:{user_id}')) + session_ids.update(sid for sid, entry in SESSION_POOL.items() if entry and entry.get('id') == user_id) + return list(session_ids) + + def get_user_ids_from_room(room): active_session_ids = get_session_ids_from_room(room) @@ -299,7 +324,7 @@ async def emit_to_users(event: str, data: dict, user_ids: list[str]): for user_id in user_ids: await sio.emit(event, data, room=f'user:{user_id}') except Exception as e: - log.debug(f'Failed to emit event {event} to users {user_ids}: {e}') + log.debug('Failed to emit event %s to users %s: %s', event, user_ids, e) async def enter_room_for_users(room: str, user_ids: list[str]): @@ -315,7 +340,7 @@ async def enter_room_for_users(room: str, user_ids: list[str]): for sid in session_ids: await sio.enter_room(sid, room) except Exception as e: - log.debug(f'Failed to make users {user_ids} join room {room}: {e}') + log.debug('Failed to make users %s join room %s: %s', user_ids, room, e) async def disconnect_user_sessions(user_id: str): @@ -326,14 +351,15 @@ async def disconnect_user_sessions(user_id: str): The client will automatically reconnect and re-authenticate with fresh data from the database. """ - try: - session_ids = get_session_ids_from_room(f'user:{user_id}') - for sid in session_ids: + session_ids = get_session_ids_by_user_id(user_id) + for sid in session_ids: + try: await sio.disconnect(sid) - if session_ids: - log.info(f'Disconnected {len(session_ids)} session(s) for user {user_id}') - except Exception as e: - log.warning(f'Failed to disconnect sessions for user {user_id}: {e}') + except Exception: + log.exception('Failed to disconnect session %s for user %s', sid, user_id) + + if session_ids: + log.info('Requested disconnect of %s session(s) for user %s', len(session_ids), user_id) @sio.on('usage') @@ -411,7 +437,7 @@ async def user_join(sid, data): # Join all the channels only if user has channels permission if user.role == 'admin' or await has_permission(user.id, 'features.channels'): channels = await Channels.get_channels_by_user_id(user.id) - log.debug(f'{channels=}') + log.debug('channels=%r', channels) for channel in channels: await sio.enter_room(sid, f'channel:{channel.id}') @@ -443,7 +469,7 @@ async def join_channel(sid, data): # Join all the channels only if user has channels permission if user.role == 'admin' or await has_permission(user.id, 'features.channels'): channels = await Channels.get_channels_by_user_id(user.id) - log.debug(f'{channels=}') + log.debug('channels=%r', channels) for channel in channels: await sio.enter_room(sid, f'channel:{channel.id}') @@ -480,7 +506,7 @@ async def join_note(sid, data): log.error(f'User {user.id} does not have access to note {data["note_id"]}') return - log.debug(f'Joining note {note.id} for user {user.id}') + log.debug('Joining note %s for user %s', note.id, user.id) await sio.enter_room(sid, f'note:{note.id}') @@ -626,7 +652,7 @@ async def ydoc_document_join(sid, data): user_name = data.get('user_name', 'Anonymous') user_color = data.get('user_color', '#000000') - log.info(f'User {user_id} joining document {document_id}') + log.info('User %s joining document %s', user_id, document_id) await YDOC_MANAGER.add_user(document_id=document_id, user_id=sid) # Join Socket.IO room @@ -665,7 +691,7 @@ async def ydoc_document_join(sid, data): skip_sid=sid, ) - log.info(f'User {user_id} successfully joined document {document_id}') + log.info('User %s successfully joined document %s', user_id, document_id) except Exception as e: log.error(f'Error in yjs_document_join: {e}') @@ -779,11 +805,6 @@ async def yjs_document_update(sid, data): log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.') return - try: - await stop_item_tasks(REDIS, document_id) - except Exception: - pass - user_id = data.get('user_id', sid) update = data['update'] # List of bytes from frontend @@ -811,6 +832,16 @@ async def yjs_document_update(sid, data): await document_save_handler(document_id, data.get('data', {}), user) if data.get('data'): + # Only drop the pending save when a new one takes its place. + # Updates without a content snapshot (the resync a client sends + # after rejoining a document) would otherwise cancel the pending + # save without scheduling a replacement, so the edits made just + # before the resync never reach the database. + try: + await stop_item_tasks(REDIS, document_id) + except Exception: + pass + await create_task(REDIS, debounced_save(), document_id) except Exception as e: @@ -826,7 +857,7 @@ async def yjs_document_leave(sid, data): try: document_id = normalize_document_id(data['document_id']) - log.info(f'User {user["id"]} leaving document {document_id}') + log.info('User %s leaving document %s', user['id'], document_id) # Remove user from the document await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid) @@ -842,7 +873,7 @@ async def yjs_document_leave(sid, data): ) if await YDOC_MANAGER.document_exists(document_id) and len(await YDOC_MANAGER.get_users(document_id)) == 0: - log.info(f'Cleaning up document {document_id} as no users are left') + log.info('Cleaning up document %s as no users are left', document_id) await YDOC_MANAGER.clear_document(document_id) except Exception as e: @@ -904,7 +935,7 @@ async def _make_channel_emitter(request_info): channel_id = request_info['chat_id'].removeprefix('channel:') message_id = request_info['message_id'] - state = {'last_emit_at': 0.0} + state = {'last_emit_at': 0.0, 'output': []} THROTTLE_INTERVAL = 0.15 # ~6 updates/sec async def _emit_channel_update(content: str, done: bool = False, output: list | None = None): @@ -952,11 +983,23 @@ async def _make_channel_emitter(request_info): if not content and not output and not done: return - now = __import__('time').time() + now = time.time() if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL: state['last_emit_at'] = now await _emit_channel_update(content, done, output if isinstance(output, list) else None) + elif event_type == 'response:completion': + from open_webui.utils.middleware import handle_responses_streaming_event + + data = event_data.get('data', {}) + state['output'], _ = handle_responses_streaming_event(data, state['output']) + content = get_output_text(state['output']) + + now = time.time() + if content and (now - state['last_emit_at']) >= THROTTLE_INTERVAL: + state['last_emit_at'] = now + await _emit_channel_update(content, False, state['output']) + elif event_type == 'chat:message:error': error = event_data.get('data', {}).get('error', {}) error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error) @@ -1038,15 +1081,13 @@ async def get_event_emitter(request_info, update_db=True): embeds = event_payload.get('embeds', []) if not event_payload.get('replace', False): - message = await Chats.get_message_by_id_and_message_id( - request_info['chat_id'], - request_info['message_id'], - ) - embeds.extend(message.get('embeds', [])) + existing_embeds = await Chats.get_message_metadata(chat_id, message_id, 'embeds') + if isinstance(existing_embeds, list): + embeds.extend(existing_embeds) await Chats.upsert_message_to_chat_by_id_and_message_id( - request_info['chat_id'], - request_info['message_id'], + chat_id, + message_id, { 'embeds': embeds, }, @@ -1054,17 +1095,14 @@ async def get_event_emitter(request_info, update_db=True): ) elif event_type == 'files': - message = await Chats.get_message_by_id_and_message_id( - request_info['chat_id'], - request_info['message_id'], - ) - files = event_data.get('data', {}).get('files', []) - files.extend(message.get('files', [])) + existing_files = await Chats.get_message_metadata(chat_id, message_id, 'files') + if isinstance(existing_files, list): + files.extend(existing_files) await Chats.upsert_message_to_chat_by_id_and_message_id( - request_info['chat_id'], - request_info['message_id'], + chat_id, + message_id, { 'files': files, }, @@ -1074,17 +1112,14 @@ async def get_event_emitter(request_info, update_db=True): elif event_type in ('source', 'citation'): data = event_data.get('data', {}) if data.get('type') is None: - message = await Chats.get_message_by_id_and_message_id( - request_info['chat_id'], - request_info['message_id'], - ) - - sources = message.get('sources', []) + sources = await Chats.get_message_metadata(chat_id, message_id, 'sources') + if not isinstance(sources, list): + sources = [] sources.append(data) await Chats.upsert_message_to_chat_by_id_and_message_id( - request_info['chat_id'], - request_info['message_id'], + chat_id, + message_id, { 'sources': sources, }, @@ -1118,13 +1153,8 @@ async def get_event_call(request_info): to=session_id, timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT, ) - except TimeoutError: + except (TimeoutError, socketio.exceptions.TimeoutError): log.warning(f'Event caller timed out for session {session_id}') - if SESSION_POOL.get(session_id) == session: - try: - del SESSION_POOL[session_id] - except KeyError: - pass return {'error': 'Event call timed out. The browser tab may be inactive or closed.'} if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info: diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 00f8424aae..09fa47de15 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -61,12 +61,16 @@ class RedisLock: class RedisDict: - def __init__(self, name, redis_url, redis_sentinels=[], redis_cluster=False): + def __init__( + self, + name, + redis_url, + redis_sentinels=[], + redis_cluster=False, + cache_set_signature=False, + ): self.name = name - # Per-process cache of the last payload fingerprint written by set(). - # Used to skip redundant HSET round-trips when the model list hasn't - # changed — the dominant Redis write source on busy multi-pod setups. - self._last_signature: str | None = None + self._signature_name = f'{name}:signature' if cache_set_signature else None self.redis = get_redis_connection( redis_url, redis_sentinels, @@ -77,6 +81,8 @@ class RedisDict: def __setitem__(self, key, value): serialized_value = JSONCodec.dumps(value) self.redis.hset(self.name, key, serialized_value) + if self._signature_name: + self.redis.delete(self._signature_name) def __getitem__(self, key): value = self.redis.hget(self.name, key) @@ -88,6 +94,8 @@ class RedisDict: result = self.redis.hdel(self.name, key) if result == 0: raise KeyError(key) + if self._signature_name: + self.redis.delete(self._signature_name) def __contains__(self, key): return self.redis.hexists(self.name, key) @@ -106,8 +114,7 @@ class RedisDict: def set(self, mapping: dict): if not mapping: - self.redis.delete(self.name) - self._last_signature = None + self.clear() return # Serialize values once — reused for both the fingerprint and the write. @@ -120,11 +127,7 @@ class RedisDict: digest.update(b'\0') signature = digest.hexdigest() - # Skip the write when the prepared mapping is identical to the last one - # this process wrote. The check is per-instance (not distributed), but - # still eliminates the majority of redundant writes because each pod - # typically produces the same model list on consecutive refreshes. - if signature == self._last_signature: + if self._signature_name and self.redis.get(self._signature_name) == signature: return # Fetch existing keys before writing so we know which ones to remove. @@ -140,7 +143,8 @@ class RedisDict: if keys_to_remove: self.redis.hdel(self.name, *keys_to_remove) - self._last_signature = signature + if self._signature_name: + self.redis.set(self._signature_name, signature) def get(self, key, default=None): try: @@ -149,8 +153,11 @@ class RedisDict: return default def clear(self): - self.redis.delete(self.name) - self._last_signature = None + if self._signature_name: + self.redis.delete(self.name) + self.redis.delete(self._signature_name) + else: + self.redis.delete(self.name) def update(self, other=None, **kwargs): if other is not None: diff --git a/backend/open_webui/static/BRANDING.md b/backend/open_webui/static/BRANDING.md new file mode 100644 index 0000000000..381cdf528b --- /dev/null +++ b/backend/open_webui/static/BRANDING.md @@ -0,0 +1,9 @@ +# Open WebUI Branding Assets + +This directory contains Open WebUI branding assets, including favicons, logos, splash images, app icons, and web app manifest icons. + +Open WebUI branding in this directory includes assets, filenames, metadata, manifests, references, and surrounding UI that present or preserve the Open WebUI name, logo, visual, textual, or symbolic identifiers. + +Do not alter, remove, obscure, or replace Open WebUI branding except as permitted by `LICENSE`. + +See also: https://docs.openwebui.com/license. diff --git a/backend/open_webui/static/README.md b/backend/open_webui/static/README.md new file mode 100644 index 0000000000..29da9e398e --- /dev/null +++ b/backend/open_webui/static/README.md @@ -0,0 +1,9 @@ +# Open WebUI Branding Assets + +This directory contains Open WebUI branding assets and references, including favicons, logos, splash images, app icons, manifests, metadata, and search identifiers. + +Open WebUI branding here includes the name, logo, and any visual, textual, or symbolic identifiers that present or preserve Open WebUI attribution. + +Do not alter, remove, obscure, or replace Open WebUI branding except as permitted by `LICENSE`. + +See also: https://docs.openwebui.com/license. diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index 7edcd56e78..9b52ef97f0 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -1,4 +1,3 @@ -import json import logging import os import re @@ -33,6 +32,7 @@ from open_webui.config import ( UPLOAD_DIR, ) from open_webui.constants import ERROR_MESSAGES +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -211,7 +211,7 @@ class GCSStorageProvider(StorageProvider): if GOOGLE_APPLICATION_CREDENTIALS_JSON: self.gcs_client = storage.Client.from_service_account_info( - info=json.loads(GOOGLE_APPLICATION_CREDENTIALS_JSON) + info=JSONCodec.loads(GOOGLE_APPLICATION_CREDENTIALS_JSON) ) else: # if no credentials json is provided, credentials will be picked up from the environment diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index e5ff754297..ef15c57a18 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -1,42 +1,66 @@ # tasks.py import asyncio -import json import logging +from contextlib import suppress from uuid import uuid4 from redis.asyncio import Redis -from open_webui.env import REDIS_KEY_PREFIX +from open_webui.env import REDIS_KEY_PREFIX, REDIS_RESPONSE_STREAM_TTL +from open_webui.utils.json_codec import JSONCodec, dumps_bytes log = logging.getLogger(__name__) # A dictionary to keep track of active tasks tasks: dict[str, asyncio.Task] = {} item_tasks = {} +response_streams: dict[str, dict] = {} REDIS_TASKS_KEY = f'{REDIS_KEY_PREFIX}:tasks' REDIS_ITEM_TASKS_KEY = f'{REDIS_KEY_PREFIX}:tasks:item' +REDIS_RESPONSE_STREAMS_KEY = f'{REDIS_KEY_PREFIX}:tasks:response_streams' REDIS_PUBSUB_CHANNEL = f'{REDIS_KEY_PREFIX}:tasks:commands' +REDIS_PUBSUB_RECONNECT_INTERVAL = 1.0 +REDIS_PUBSUB_MAX_RECONNECT_INTERVAL = 30.0 async def redis_task_command_listener(app): redis: Redis = app.state.redis - pubsub = redis.pubsub() - await pubsub.subscribe(REDIS_PUBSUB_CHANNEL) + reconnect_interval = REDIS_PUBSUB_RECONNECT_INTERVAL - async for message in pubsub.listen(): - if message['type'] != 'message': - continue + while True: + pubsub = None try: - command = json.loads(message['data']) - if command.get('action') == 'stop': - task_id = command.get('task_id') - local_task = tasks.get(task_id) - if local_task: - local_task.cancel() + pubsub = redis.pubsub() + await pubsub.subscribe(REDIS_PUBSUB_CHANNEL) + reconnect_interval = REDIS_PUBSUB_RECONNECT_INTERVAL + + async for message in pubsub.listen(): + if message['type'] != 'message': + continue + try: + command = JSONCodec.loads(message['data']) + if command.get('action') != 'stop': + continue + + local_task = tasks.get(command.get('task_id')) + if local_task: + local_task.cancel() + except Exception as e: + log.exception(f'Error handling distributed task command: {e}') + log.warning('Redis task command listener stopped. Retrying.') + except asyncio.CancelledError: + raise except Exception as e: - log.exception(f'Error handling distributed task command: {e}') + log.exception(f'Redis task command listener failed. Retrying: {e}') + finally: + if pubsub: + with suppress(Exception): + await pubsub.aclose() + + await asyncio.sleep(reconnect_interval) + reconnect_interval = min(reconnect_interval * 2, REDIS_PUBSUB_MAX_RECONNECT_INTERVAL) ### ------------------------------ @@ -55,6 +79,7 @@ async def redis_save_task(redis: Redis, task_id: str, item_id: str | None): async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None): pipe = redis.pipeline() pipe.hdel(REDIS_TASKS_KEY, task_id) + pipe.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id) if item_id: pipe.srem(f'{REDIS_ITEM_TASKS_KEY}:{item_id}', task_id) await pipe.execute() @@ -74,7 +99,7 @@ async def redis_list_item_tasks(redis: Redis, item_id: str) -> list[str]: async def redis_send_command(redis: Redis, command: dict): - command_json = json.dumps(command) + command_json = dumps_bytes(command) # RedisCluster doesn't expose publish() directly, but the # PUBLISH command broadcasts across all cluster nodes server-side. if hasattr(redis, 'nodes_manager'): @@ -91,6 +116,7 @@ async def cleanup_task(redis, task_id: str, id=None): await redis_cleanup_task(redis, task_id, id) tasks.pop(task_id, None) # Remove the task if it exists + response_streams.pop(task_id, None) # If an ID is provided, remove the task from the item_tasks dictionary if id and task_id in item_tasks.get(id, []): @@ -140,6 +166,66 @@ async def list_task_ids_by_item_id(redis, id): return item_tasks.get(id, []) +async def save_response_stream( + redis, + task_id: str | None, + chat_id: str | None, + message_id: str | None, + content: str, + output: list, +): + if not task_id or not chat_id or not message_id: + return + + data = { + 'chat_id': chat_id, + 'message_id': message_id, + 'content': content, + 'output': output, + } + + if redis: + await redis.hset(REDIS_RESPONSE_STREAMS_KEY, task_id, dumps_bytes(data)) + if REDIS_RESPONSE_STREAM_TTL > 0: + with suppress(Exception): + await redis.hexpire(REDIS_RESPONSE_STREAMS_KEY, REDIS_RESPONSE_STREAM_TTL, task_id) + else: + response_streams[task_id] = data + + +async def get_response_streams_by_chat_id(redis, chat_id: str) -> list[dict]: + task_ids = await list_task_ids_by_item_id(redis, chat_id) + if not task_ids: + return [] + + if redis: + values = await redis.hmget(REDIS_RESPONSE_STREAMS_KEY, task_ids) + streams = [] + for value in values: + if not value: + continue + try: + data = JSONCodec.loads(value) + except Exception: + continue + if data.get('chat_id') == chat_id: + streams.append(data) + return streams + + return [ + stream for task_id in task_ids if (stream := response_streams.get(task_id)) and stream.get('chat_id') == chat_id + ] + + +async def clear_response_stream(redis, task_id: str | None): + if not task_id: + return + if redis: + await redis.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id) + else: + response_streams.pop(task_id, None) + + async def stop_task(redis, task_id: str): """ Cancel a running task and remove it from the global task list. diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 1b721f19a1..e886dad315 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -6,10 +6,7 @@ These tools are automatically available when native function calling is enabled. IMPORTANT: DO NOT IMPORT THIS MODULE DIRECTLY IN OTHER PARTS OF THE CODEBASE. """ -from open_webui.tools.knowledge_fs import kb_exec # noqa: F401 — re-exported - import asyncio -import json import logging import time from typing import Literal, Optional @@ -22,8 +19,9 @@ from open_webui.env import ( VIEW_FILE_DEFAULT_MAX_CHARS, VIEW_FILE_MAX_CHARS, ) +from open_webui.events import EVENTS, publish_event from open_webui.models.channels import Channel, ChannelMember, Channels -from open_webui.models.chats import Chats +from open_webui.models.chats import Chats, chat_search_content_query, chat_search_terms from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.memories import Memories @@ -45,20 +43,29 @@ from open_webui.routers.memories import ( ReadMemoryPathForm, SearchMemoriesForm, UpdateMemoriesForm, - list_memory_paths as _list_memory_paths, - read_memory_path as _read_memory_path, - search_memories as _search_memories, - update_memories as _update_memories, update_memory_by_id, ) from open_webui.routers.memories import ( add_memory as _add_memory, ) +from open_webui.routers.memories import ( + list_memory_paths as _list_memory_paths, +) +from open_webui.routers.memories import ( + read_memory_path as _read_memory_path, +) +from open_webui.routers.memories import ( + search_memories as _search_memories, +) +from open_webui.routers.memories import ( + update_memories as _update_memories, +) from open_webui.routers.retrieval import search_web as _search_web -from open_webui.tasks import stop_item_tasks -from open_webui.events import EVENTS, publish_event from open_webui.socket.main import sio +from open_webui.tasks import stop_item_tasks +from open_webui.tools.knowledge_fs import kb_exec # noqa: F401 — re-exported from open_webui.utils.chat_id import is_saved_chat_id +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.notifications import notify_target from open_webui.utils.sanitize import sanitize_code @@ -96,11 +103,12 @@ async def _emit_note_updated(request: Request, user: dict, note) -> None: async def _has_read_access_to_file( file, - user_id: str, - user_role: str, + user: dict, model_knowledge: Optional[list[dict]] = None, ) -> bool: """Check if a user can read a file via ownership, admin role, model attachment, or access grants.""" + user_id = user.get('id') + user_role = user.get('role', 'user') if file.user_id == user_id or user_role == 'admin': return True if model_knowledge and any(item.get('type') == 'file' and item.get('id') == file.id for item in model_knowledge): @@ -110,7 +118,7 @@ async def _has_read_access_to_file( return await has_access_to_file( file_id=file.id, access_type='read', - user=UserModel(**{'id': user_id, 'role': user_role}), + user=UserModel(**user), ) @@ -138,6 +146,9 @@ async def notify( return 'Notification failed: user not found.' app_name = getattr(getattr(__request__, 'app', None), 'state', None) + # LICENSE covers this Open WebUI notification identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. app_name = getattr(app_name, 'WEBUI_NAME', 'Open WebUI') try: result = await notify_target(user_id, message, target=target, title=title, app_name=app_name) @@ -176,10 +187,10 @@ async def get_current_timestamp( except Exception: pass - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'get_current_timestamp error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def calculate_timestamp( @@ -239,7 +250,7 @@ async def calculate_timestamp( except Exception: pass - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) except ImportError: # Fallback without dateutil import datetime @@ -268,10 +279,10 @@ async def calculate_timestamp( except Exception: pass - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'calculate_timestamp error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -294,7 +305,7 @@ async def search_web( :return: JSON with search results containing title, link, and snippet for each result """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: engine = await Config.get('web.search.engine') @@ -309,13 +320,13 @@ async def search_web( # Limit results results = results[:count] if results else [] - return json.dumps( + return JSONCodec.dumps( [{'title': r.title, 'link': r.link, 'snippet': r.snippet} for r in results], ensure_ascii=False, ) except Exception as e: log.exception(f'search_web error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def fetch_url( @@ -330,7 +341,7 @@ async def fetch_url( :return: The extracted text content from the page """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: content, _ = await get_content_from_url(__request__, url) @@ -347,7 +358,7 @@ async def fetch_url( return content except Exception as e: log.warning(f'fetch_url error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -370,7 +381,7 @@ async def generate_image( :return: Confirmation that the image was generated, or an error message """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -405,7 +416,7 @@ async def generate_image( } ) # Return a message indicating the image is already displayed - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'message': 'The image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.', @@ -414,10 +425,10 @@ async def generate_image( ensure_ascii=False, ) - return json.dumps({'status': 'success', 'images': images}, ensure_ascii=False) + return JSONCodec.dumps({'status': 'success', 'images': images}, ensure_ascii=False) except Exception as e: log.exception(f'generate_image error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def edit_image( @@ -438,7 +449,7 @@ async def edit_image( :return: Confirmation that the images were edited, or an error message """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -473,7 +484,7 @@ async def edit_image( } ) # Return a message indicating the image is already displayed - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'message': 'The edited image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.', @@ -482,10 +493,124 @@ async def edit_image( ensure_ascii=False, ) - return json.dumps({'status': 'success', 'images': images}, ensure_ascii=False) + return JSONCodec.dumps({'status': 'success', 'images': images}, ensure_ascii=False) except Exception as e: log.exception(f'edit_image error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) + + +# ============================================================================= +# USER INPUT TOOLS +# ============================================================================= + + +async def ask_user( + questions: list[dict], + allow_other: bool = True, + timeout_ms: int = 120_000, + __event_call__: callable = None, +) -> str: + """ + Ask the user clarifying questions before continuing. + Use this when the next step depends on user intent, preference, or a tradeoff that cannot be inferred safely. + + :param questions: 1-3 question objects, each with id, header, question, and 2-3 options. Each option needs label and description. + :param allow_other: Whether users may enter a free-form answer instead of choosing one of the options + :param timeout_ms: How long the browser should keep the prompt open before cancelling it + :return: JSON with status and answers keyed by question id + """ + try: + if not isinstance(questions, list) or not 1 <= len(questions) <= 3: + raise ValueError('ask_user requires 1-3 questions.') + + normalized_questions = [] + seen_ids = set() + for index, question in enumerate(questions): + if not isinstance(question, dict): + raise ValueError('Each question must be an object.') + + question_id = str(question.get('id') or '').strip()[:64] + if not question_id: + raise ValueError('Each question requires a non-empty id.') + if question_id in seen_ids: + raise ValueError(f'Duplicate question id: {question_id}') + seen_ids.add(question_id) + + options = question.get('options') + if not isinstance(options, list) or not 2 <= len(options) <= 3: + raise ValueError('Each question requires 2-3 options.') + + normalized_options = [] + for option in options: + if not isinstance(option, dict): + raise ValueError('Each option must be an object.') + + label = str(option.get('label') or '').strip()[:80] + description = str(option.get('description') or '').strip()[:240] + if not label or not description: + raise ValueError('Each option requires a label and description.') + + normalized_options.append( + { + 'label': label, + 'description': description, + } + ) + + question_text = str(question.get('question') or '').strip()[:500] + if not question_text: + raise ValueError('Each question requires question text.') + + normalized_questions.append( + { + 'id': question_id, + 'header': str(question.get('header') or '').strip()[:48] or f'Question {index + 1}', + 'question': question_text, + 'options': normalized_options, + 'allow_other': bool(question.get('allow_other', allow_other)), + } + ) + + if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or not 60_000 <= timeout_ms <= 240_000: + timeout_ms = 120_000 + + if __event_call__ is None: + return JSONCodec.dumps( + { + 'status': 'error', + 'error': 'User input requires an active browser session with WebSocket connection.', + }, + ensure_ascii=False, + ) + + output = await __event_call__( + { + 'type': 'request:user_input', + 'data': { + 'questions': normalized_questions, + 'allow_other': allow_other, + 'timeout_ms': timeout_ms, + }, + } + ) + + if not isinstance(output, dict): + return JSONCodec.dumps({'status': 'error', 'error': 'Invalid user input response.'}, ensure_ascii=False) + if output.get('error'): + return JSONCodec.dumps({'status': 'error', 'error': output.get('error')}, ensure_ascii=False) + if output.get('status') == 'cancelled': + return JSONCodec.dumps({'status': 'cancelled', 'answers': {}}, ensure_ascii=False) + + return JSONCodec.dumps( + { + 'status': 'answered', + 'answers': output.get('answers', {}), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'ask_user error: {e}') + return JSONCodec.dumps({'status': 'error', 'error': str(e)}, ensure_ascii=False) # ============================================================================= @@ -514,7 +639,7 @@ async def execute_code( from uuid import uuid4 if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: # Sanitize code (strips ANSI codes and markdown fences) @@ -552,7 +677,7 @@ async def execute_code( if engine == 'pyodide': # Execute via frontend pyodide using bidirectional event call if __event_call__ is None: - return json.dumps( + return JSONCodec.dumps( {'error': 'Event call not available. WebSocket connection required for pyodide execution.'} ) @@ -602,7 +727,7 @@ async def execute_code( result = output.get('result', '') else: - return json.dumps({'error': f'Unknown code interpreter engine: {engine}'}) + return JSONCodec.dumps({'error': f'Unknown code interpreter engine: {engine}'}) # Handle image outputs (base64 encoded) - replace with uploaded URLs # Get actual user object for image upload (upload_image requires user.id attribute) @@ -649,10 +774,10 @@ async def execute_code( 'result': result, } - return json.dumps(response, ensure_ascii=False) + return JSONCodec.dumps(response, ensure_ascii=False) except Exception as e: log.exception(f'execute_code error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -685,10 +810,10 @@ async def list_memory_paths( ), user, ) - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'list_memory_paths error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def read_memory_path( @@ -719,10 +844,10 @@ async def read_memory_path( ), user, ) - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'read_memory_path error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def search_memories( @@ -745,7 +870,7 @@ async def search_memories( :return: JSON with matching memories and their dates """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -762,9 +887,9 @@ async def search_memories( ) if not memories: - return json.dumps([]) + return JSONCodec.dumps([]) - return json.dumps( + return JSONCodec.dumps( [ { 'id': memory.id, @@ -780,7 +905,7 @@ async def search_memories( ) except Exception as e: log.exception(f'search_memories error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def add_memory( @@ -803,7 +928,7 @@ async def add_memory( :return: Confirmation that the memory was stored """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -814,13 +939,13 @@ async def add_memory( user, ) - return json.dumps( + return JSONCodec.dumps( {'status': 'success', 'id': memory.id, 'type': memory.type, 'path': memory.path}, ensure_ascii=False, ) except Exception as e: log.exception(f'add_memory error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def update_memory( @@ -849,7 +974,7 @@ async def update_memory( :return: JSON with operation results """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -858,10 +983,10 @@ async def update_memory( UpdateMemoriesForm(operations=operations), user, ) - return json.dumps(operation_results, ensure_ascii=False) + return JSONCodec.dumps(operation_results, ensure_ascii=False) except Exception as e: log.exception(f'update_memory error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def replace_memory_content( @@ -882,7 +1007,7 @@ async def replace_memory_content( :return: Confirmation that the memory was updated """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -898,7 +1023,7 @@ async def replace_memory_content( user=user, ) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'id': memory.id, @@ -910,7 +1035,7 @@ async def replace_memory_content( ) except Exception as e: log.exception(f'replace_memory_content error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def delete_memory( @@ -925,7 +1050,7 @@ async def delete_memory( :return: Confirmation that the memory was deleted """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -934,15 +1059,15 @@ async def delete_memory( if result: await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) - return json.dumps( + return JSONCodec.dumps( {'status': 'success', 'message': f'Memory {memory_id} deleted'}, ensure_ascii=False, ) else: - return json.dumps({'error': 'Memory not found or access denied'}) + return JSONCodec.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)}) + return JSONCodec.dumps({'error': str(e)}) async def list_memories( @@ -955,7 +1080,7 @@ async def list_memories( :return: JSON list of all memories with id, content, and dates """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -974,12 +1099,12 @@ async def list_memories( } for m in memories ] - return json.dumps(memory_rows, ensure_ascii=False) + return JSONCodec.dumps(memory_rows, ensure_ascii=False) else: - return json.dumps([]) + return JSONCodec.dumps([]) except Exception as e: log.exception(f'list_memories error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -1005,10 +1130,10 @@ async def search_notes( :return: JSON with matching notes containing id, title, and content snippet """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: user_id = __user__.get('id') @@ -1078,10 +1203,10 @@ async def search_notes( if len(notes) >= count: break - return json.dumps(notes, ensure_ascii=False) + return JSONCodec.dumps(notes, ensure_ascii=False) except Exception as e: log.exception(f'search_notes error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def view_note( @@ -1096,16 +1221,16 @@ async def view_note( :return: JSON with the note's id, title, and full markdown content """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: note = await Notes.get_note_by_id(note_id) if not note: - return json.dumps({'error': 'Note not found'}) + return JSONCodec.dumps({'error': 'Note not found'}) # Check access permission user_id = __user__.get('id') @@ -1124,14 +1249,14 @@ async def view_note( user_group_ids=set(user_group_ids), ) ): - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) # Extract markdown content content = '' if note.data and note.data.get('content', {}).get('md'): content = note.data['content']['md'] - return json.dumps( + return JSONCodec.dumps( { 'id': note.id, 'title': note.title, @@ -1143,7 +1268,7 @@ async def view_note( ) except Exception as e: log.exception(f'view_note error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def write_note( @@ -1160,10 +1285,10 @@ async def write_note( :return: JSON with success status and new note id """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.notes import NoteForm @@ -1179,9 +1304,9 @@ async def write_note( new_note = await Notes.insert_new_note(user_id, form) if not new_note: - return json.dumps({'error': 'Failed to create note'}) + return JSONCodec.dumps({'error': 'Failed to create note'}) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'id': new_note.id, @@ -1192,7 +1317,7 @@ async def write_note( ) except Exception as e: log.exception(f'write_note error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def replace_note_content( @@ -1215,10 +1340,10 @@ async def replace_note_content( :return: JSON with success status and updated note info """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.notes import NoteUpdateForm @@ -1226,22 +1351,22 @@ async def replace_note_content( note = await Notes.get_note_by_id(note_id) if not note: - return json.dumps({'error': 'Note not found', 'code': 'not_found'}) + return JSONCodec.dumps({'error': 'Note not found', 'code': 'not_found'}) user_id = __user__.get('id') if __user__.get('role') != 'admin' and not await _has_write_access_to_note(note, user_id): - return json.dumps({'error': 'Write access denied', 'code': 'write_access_denied'}) + return JSONCodec.dumps({'error': 'Write access denied', 'code': 'write_access_denied'}) current_content = ((note.data or {}).get('content') or {}).get('md') or '' applied_operation_count = 0 if operations is not None: if not isinstance(operations, list) or len(operations) == 0: - return json.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'}) + return JSONCodec.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'}) range_operations = [] for idx, operation in enumerate(operations): if not isinstance(operation, dict): - return json.dumps( + return JSONCodec.dumps( {'error': 'each operation must be an object', 'code': 'invalid_operation', 'index': idx} ) @@ -1250,7 +1375,7 @@ async def replace_note_content( if action == 'replace': if len(operations) != 1: - return json.dumps( + return JSONCodec.dumps( { 'error': 'replace operation must be the only operation', 'code': 'invalid_operations', @@ -1258,7 +1383,7 @@ async def replace_note_content( } ) if not isinstance(replacement, str): - return json.dumps( + return JSONCodec.dumps( { 'error': 'replace operation content must be a string', 'code': 'invalid_content', @@ -1270,7 +1395,7 @@ async def replace_note_content( break if action != 'replace_range': - return json.dumps( + return JSONCodec.dumps( {'error': 'unknown operation action', 'code': 'invalid_action', 'index': idx, 'action': action} ) @@ -1278,19 +1403,19 @@ async def replace_note_content( end = operation.get('end') expected = operation.get('expected') if not isinstance(start, int) or not isinstance(end, int): - return json.dumps( + return JSONCodec.dumps( {'error': 'operation start and end must be integers', 'code': 'invalid_range', 'index': idx} ) if not isinstance(replacement, str): - return json.dumps( + return JSONCodec.dumps( {'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx} ) if start < 0 or end < start or end > len(current_content): - return json.dumps( + return JSONCodec.dumps( {'error': 'operation range is out of bounds', 'code': 'range_out_of_bounds', 'index': idx} ) if expected is not None and current_content[start:end] != expected: - return json.dumps( + return JSONCodec.dumps( { 'error': 'operation expected text does not match current content', 'code': 'expected_mismatch', @@ -1304,7 +1429,7 @@ async def replace_note_content( previous_end = 0 for idx, operation in enumerate(range_operations): if operation['start'] < previous_end: - return json.dumps( + return JSONCodec.dumps( {'error': 'operation ranges must not overlap', 'code': 'overlapping_operations', 'index': idx} ) previous_end = operation['end'] @@ -1315,7 +1440,7 @@ async def replace_note_content( content = content[: operation['start']] + operation['content'] + content[operation['end'] :] applied_operation_count = len(range_operations) elif content is None: - return json.dumps({'error': 'content or operations is required', 'code': 'content_required'}) + return JSONCodec.dumps({'error': 'content or operations is required', 'code': 'content_required'}) try: await stop_item_tasks(__request__.app.state.redis, f'note:{note_id}') @@ -1340,11 +1465,11 @@ async def replace_note_content( updated_note = await Notes.update_note_by_id(note_id, form) if not updated_note: - return json.dumps({'error': 'Failed to update note', 'code': 'update_failed'}) + return JSONCodec.dumps({'error': 'Failed to update note', 'code': 'update_failed'}) await _emit_note_updated(__request__, __user__, updated_note) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'id': updated_note.id, @@ -1356,7 +1481,7 @@ async def replace_note_content( ) except Exception as e: log.exception(f'replace_note_content error: {e}') - return json.dumps({'error': str(e), 'code': 'unexpected_error'}) + return JSONCodec.dumps({'error': str(e), 'code': 'unexpected_error'}) # ============================================================================= @@ -1374,20 +1499,23 @@ async def search_chats( __chat_id__: str = None, ) -> str: """ - Search the user's previous chat conversations by title and message content. - Helpful for finding details from earlier conversations. + Search the user's previous chat conversations by title and message content, + excluding the current chat. Helpful for finding details from earlier + conversations when they are not already visible in the current context. + Exact phrase matches are preferred, and descriptive keyword queries are + supported. - :param query: The search query to find matching chats + :param query: Exact phrase or descriptive keyword query to find matching previous chats :param count: Maximum number of results to return (default: 5) :param start_timestamp: Only include chats updated after this Unix timestamp (seconds) :param end_timestamp: Only include chats updated before this Unix timestamp (seconds) :return: JSON with matching chats containing id, title, updated_at, and content snippet """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: user_id = __user__.get('id') @@ -1415,19 +1543,31 @@ async def search_chats( # Find a matching message snippet snippet = '' messages = (getattr(chat, 'chat', None) or {}).get('history', {}).get('messages', {}) - lower_query = query.lower() + if not messages: + messages = (getattr(chat, 'chat', None) or {}).get('messages', {}) or {} + if isinstance(messages, list): + messages = {str(idx): message for idx, message in enumerate(messages)} - for msg_id, msg in messages.items(): - content = msg.get('content', '') - if isinstance(content, str) and lower_query in content.lower(): - idx = content.lower().find(lower_query) - start = max(0, idx - 50) - end = min(len(content), idx + len(query) + 100) - snippet = ('...' if start > 0 else '') + content[start:end] + ('...' if end < len(content) else '') + lower_query = chat_search_content_query(query) + needles = list(dict.fromkeys([lower_query, *chat_search_terms(lower_query)])) if lower_query else [] + + for needle in needles: + for msg_id, msg in messages.items(): + content = msg.get('content', '') if isinstance(msg, dict) else '' + if isinstance(content, str) and needle in content.lower(): + idx = content.lower().find(needle) + start = max(0, idx - 50) + end = min(len(content), idx + len(needle) + 100) + snippet = ( + ('...' if start > 0 else '') + content[start:end] + ('...' if end < len(content) else '') + ) + break + if snippet: break - if not snippet and lower_query in chat.title.lower(): - snippet = f'Title match: {chat.title}' + title = chat.title or '' + if not snippet and any(needle in title.lower() for needle in needles): + snippet = f'Title match: {title}' results.append( { @@ -1441,10 +1581,10 @@ async def search_chats( if len(results) >= count: break - return json.dumps(results, ensure_ascii=False) + return JSONCodec.dumps(results, ensure_ascii=False) except Exception as e: log.exception(f'search_chats error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def view_chat( @@ -1460,10 +1600,10 @@ async def view_chat( :return: JSON with the chat's id, title, and messages """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: user_id = __user__.get('id') @@ -1471,7 +1611,7 @@ async def view_chat( chat = await Chats.get_chat_by_id_and_user_id(chat_id, user_id) if not chat: - return json.dumps({'error': 'Chat not found or access denied'}) + return JSONCodec.dumps({'error': 'Chat not found or access denied'}) # Extract messages from history messages = [] @@ -1497,7 +1637,7 @@ async def view_chat( # Reverse to get chronological order messages.reverse() - return json.dumps( + return JSONCodec.dumps( { 'id': chat.id, 'title': chat.title, @@ -1509,7 +1649,7 @@ async def view_chat( ) except Exception as e: log.exception(f'view_chat error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -1520,6 +1660,7 @@ async def view_chat( async def delegate_task( task: str, context: str = '', + file_ids: list[str] | None = None, background: bool = False, __request__: Request = None, __user__: dict = None, @@ -1532,6 +1673,8 @@ async def delegate_task( :param task: The specific task for the sub-agent to complete :param context: Relevant context, decisions, or file paths for the task + :param file_ids: Attached file IDs the sub-agent needs. Use this for images or files; + do not put file IDs only in context. :param background: Return immediately and continue this chat when the sub-agent finishes :return: Foreground result text, or a JSON dispatch handle for background work """ @@ -1546,6 +1689,7 @@ async def delegate_task( task, context, background, + file_ids=file_ids, request=__request__, user_data=__user__ or {}, metadata=__metadata__ or {}, @@ -1610,10 +1754,10 @@ async def search_channels( :return: JSON with matching channels containing id, name, description, and type """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: user_id = __user__.get('id') @@ -1642,10 +1786,10 @@ async def search_channels( if len(matching_channels) >= count: break - return json.dumps(matching_channels, ensure_ascii=False) + return JSONCodec.dumps(matching_channels, ensure_ascii=False) except Exception as e: log.exception(f'search_channels error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def search_channel_messages( @@ -1667,10 +1811,10 @@ async def search_channel_messages( :return: JSON with matching messages containing channel info, message content, and thread context """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: user_id = __user__.get('id') @@ -1681,7 +1825,7 @@ async def search_channel_messages( channel_map = {c.id: c for c in user_channels} if not channel_ids: - return json.dumps([]) + return JSONCodec.dumps([]) # Convert timestamps to nanoseconds (Message.created_at is in nanoseconds) start_ts = start_timestamp * 1_000_000_000 if start_timestamp else None @@ -1723,10 +1867,10 @@ async def search_channel_messages( } ) - return json.dumps(results, ensure_ascii=False) + return JSONCodec.dumps(results, ensure_ascii=False) except Exception as e: log.exception(f'search_channel_messages error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def view_channel_message( @@ -1741,10 +1885,10 @@ async def view_channel_message( :return: JSON with the message content, channel info, and thread replies if any """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: user_id = __user__.get('id') @@ -1752,19 +1896,19 @@ async def view_channel_message( message = await Messages.get_message_by_id(message_id) if not message: - return json.dumps({'error': 'Message not found'}) + return JSONCodec.dumps({'error': 'Message not found'}) # Verify user has access to the channel channel = await Channels.get_channel_by_id(message.channel_id) if not channel: - return json.dumps({'error': 'Channel not found'}) + return JSONCodec.dumps({'error': 'Channel not found'}) # Check if user has access to the channel user_channels = await Channels.get_channels_by_user_id(user_id) channel_ids = [c.id for c in user_channels] if message.channel_id not in channel_ids: - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) # Build response with thread information result = { @@ -1784,10 +1928,10 @@ async def view_channel_message( if message.user: result['user_name'] = message.user.name - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'view_channel_message error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def view_channel_thread( @@ -1802,10 +1946,10 @@ async def view_channel_thread( :return: JSON with the parent message and all thread replies in chronological order """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: user_id = __user__.get('id') @@ -1814,18 +1958,18 @@ async def view_channel_thread( parent_message = await Messages.get_message_by_id(parent_message_id) if not parent_message: - return json.dumps({'error': 'Message not found'}) + return JSONCodec.dumps({'error': 'Message not found'}) # Verify user has access to the channel channel = await Channels.get_channel_by_id(parent_message.channel_id) if not channel: - return json.dumps({'error': 'Channel not found'}) + return JSONCodec.dumps({'error': 'Channel not found'}) user_channels = await Channels.get_channels_by_user_id(user_id) channel_ids = [c.id for c in user_channels] if parent_message.channel_id not in channel_ids: - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) # Get all thread replies thread_replies = await Messages.get_thread_replies_by_message_id(parent_message_id) @@ -1859,7 +2003,7 @@ async def view_channel_thread( } ) - return json.dumps( + return JSONCodec.dumps( { 'channel_id': parent_message.channel_id, 'channel_name': channel.name, @@ -1871,7 +2015,7 @@ async def view_channel_thread( ) except Exception as e: log.exception(f'view_channel_thread error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -1894,10 +2038,10 @@ async def list_knowledge_bases( :return: JSON with KBs containing id, name, description, and file_count """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.knowledge import Knowledges @@ -1931,10 +2075,10 @@ async def list_knowledge_bases( } ) - return json.dumps(knowledge_bases, ensure_ascii=False) + return JSONCodec.dumps(knowledge_bases, ensure_ascii=False) except Exception as e: log.exception(f'list_knowledge_bases error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def search_knowledge_bases( @@ -1954,10 +2098,10 @@ async def search_knowledge_bases( :return: JSON with matching KBs containing id, name, description, and file_count """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.knowledge import Knowledges @@ -1991,10 +2135,10 @@ async def search_knowledge_bases( } ) - return json.dumps(knowledge_bases, ensure_ascii=False) + return JSONCodec.dumps(knowledge_bases, ensure_ascii=False) except Exception as e: log.exception(f'search_knowledge_bases error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def search_knowledge_files( @@ -2018,10 +2162,10 @@ async def search_knowledge_files( :return: JSON with matching files containing id, filename, and updated_at """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.access_grants import AccessGrants @@ -2048,7 +2192,7 @@ async def search_knowledge_files( # If knowledge_id specified, verify it's in the attached set if knowledge_id: if knowledge_id not in attached_kb_ids: - return json.dumps({'error': f'Knowledge base {knowledge_id} is not attached to this model'}) + return JSONCodec.dumps({'error': f'Knowledge base {knowledge_id} is not attached to this model'}) attached_kb_ids = {knowledge_id} all_files = [] @@ -2107,7 +2251,7 @@ async def search_knowledge_files( # Apply pagination across combined results all_files = all_files[skip : skip + count] - return json.dumps(all_files, ensure_ascii=False) + return JSONCodec.dumps(all_files, ensure_ascii=False) # No attached knowledge - search all accessible KBs if knowledge_id: @@ -2124,7 +2268,7 @@ async def search_knowledge_files( user_group_ids=set(user_group_ids), ) ): - return json.dumps({'error': f'Access denied to knowledge base {knowledge_id}'}) + return JSONCodec.dumps({'error': f'Access denied to knowledge base {knowledge_id}'}) result = await Knowledges.search_files_by_id( knowledge_id=knowledge_id, @@ -2156,10 +2300,10 @@ async def search_knowledge_files( file_info['knowledge_name'] = file.collection.get('name', '') files.append(file_info) - return json.dumps(files, ensure_ascii=False) + return JSONCodec.dumps(files, ensure_ascii=False) except Exception as e: log.exception(f'search_knowledge_files error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def _get_accessible_chat_files( @@ -2169,8 +2313,6 @@ async def _get_accessible_chat_files( ) -> list[tuple[dict, object]]: from open_webui.models.files import Files - user_id = user.get('id') - user_role = user.get('role', 'user') accessible = [] seen = set() @@ -2192,7 +2334,7 @@ async def _get_accessible_chat_files( seen.add(fid) file = await Files.get_file_by_id(fid) - if file and await _has_read_access_to_file(file, user_id, user_role): + if file and await _has_read_access_to_file(file, user): accessible.append((normalized, file)) return accessible @@ -2208,7 +2350,7 @@ def _grep_file_models( matches, err = build_matcher(pattern, case_insensitive) if err: - return json.dumps({'error': err}) + return JSONCodec.dumps({'error': err}) results = [] total_matches = 0 @@ -2259,10 +2401,10 @@ async def list_chat_files( :return: JSON with attached chat files containing id, filename, content type, size, and updated time when available """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: files = [] @@ -2282,10 +2424,10 @@ async def list_chat_files( file_info['size'] = size files.append(file_info) - return json.dumps(files, ensure_ascii=False) + return JSONCodec.dumps(files, ensure_ascii=False) except Exception as e: log.exception(f'list_chat_files error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def grep_chat_files( @@ -2308,13 +2450,13 @@ async def grep_chat_files( :return: Matching lines with file IDs, filenames, and line numbers """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) if not pattern or not pattern.strip(): - return json.dumps({'error': 'Pattern is required'}) + return JSONCodec.dumps({'error': 'Pattern is required'}) if isinstance(file_id, str) and file_id.lower() in ('none', 'null', ''): file_id = None @@ -2329,18 +2471,18 @@ async def grep_chat_files( attached_ids.add(fid) if not attached_ids: - return json.dumps({'error': 'No files are attached to this chat'}) + return JSONCodec.dumps({'error': 'No files are attached to this chat'}) if file_id and file_id not in attached_ids: - return json.dumps({'error': 'File not found'}) + return JSONCodec.dumps({'error': 'File not found'}) files_to_search = [file for _, file in await _get_accessible_chat_files(__files__, __user__, file_id)] if not files_to_search: - return json.dumps({'error': 'No accessible files found'}) + return JSONCodec.dumps({'error': 'No accessible files found'}) return _grep_file_models(files_to_search, pattern, case_insensitive, count_only) except Exception as e: log.exception(f'grep_chat_files error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def query_chat_files( @@ -2361,10 +2503,10 @@ async def query_chat_files( :return: JSON with relevant chunks containing content, source filename, and relevance score """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) if isinstance(file_id, str) and file_id.lower() in ('none', 'null', ''): file_id = None @@ -2389,13 +2531,13 @@ async def query_chat_files( attached_ids.add(fid) if not attached_ids: - return json.dumps({'error': 'No files are attached to this chat'}) + return JSONCodec.dumps({'error': 'No files are attached to this chat'}) if file_id and file_id not in attached_ids: - return json.dumps({'error': 'File not found'}) + return JSONCodec.dumps({'error': 'File not found'}) accessible = await _get_accessible_chat_files(__files__, __user__, file_id) if not accessible: - return json.dumps({'error': 'No accessible files found'}) + return JSONCodec.dumps({'error': 'No accessible files found'}) file_items = [{**item} for item, _ in accessible] rag_config = await Config.get_many( @@ -2412,12 +2554,9 @@ async def query_chat_files( embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None) if not embedding_function and not full_context: - return json.dumps({'error': 'Embedding function not configured'}) + return JSONCodec.dumps({'error': 'Embedding function not configured'}) - user_model = UserModel.model_construct( - id=__user__.get('id'), - role=__user__.get('role', 'user'), - ) + user_model = UserModel(**__user__) sources = await get_sources_from_items( request=__request__, items=file_items, @@ -2459,10 +2598,10 @@ async def query_chat_files( chunk['distance'] = distances[idx] chunks.append(chunk) - return json.dumps(chunks[:count], ensure_ascii=False) + return JSONCodec.dumps(chunks[:count], ensure_ascii=False) except Exception as e: log.exception(f'query_chat_files error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def grep_knowledge_files( @@ -2487,13 +2626,13 @@ async def grep_knowledge_files( :return: Matching lines with file IDs, filenames, and line numbers """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) if not pattern or not pattern.strip(): - return json.dumps({'error': 'Pattern is required'}) + return JSONCodec.dumps({'error': 'Pattern is required'}) try: from open_webui.models.files import Files @@ -2510,8 +2649,8 @@ async def grep_knowledge_files( # Single file mode — verify access file = await Files.get_file_by_id(file_id) if file: - if not await _has_read_access_to_file(file, user_id, user_role, __model_knowledge__): - return json.dumps({'error': 'File not found'}) + if not await _has_read_access_to_file(file, __user__, __model_knowledge__): + return JSONCodec.dumps({'error': 'File not found'}) files_to_search.append(file) elif __model_knowledge__: # Scoped to model's attached knowledge @@ -2576,13 +2715,13 @@ async def grep_knowledge_files( seen_ids.add(fid) if not files_to_search: - return json.dumps({'error': 'No accessible files found'}) + return JSONCodec.dumps({'error': 'No accessible files found'}) return _grep_file_models(files_to_search, pattern, case_insensitive, count_only) except Exception as e: log.exception(f'grep_knowledge_files error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def view_file( @@ -2608,10 +2747,10 @@ async def view_file( :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) # Coerce parameters from LLM tool calls (may come as strings) if isinstance(offset, str): @@ -2632,15 +2771,12 @@ async def view_file( try: from open_webui.models.files import Files - user_id = __user__.get('id') - user_role = __user__.get('role', 'user') - file = await Files.get_file_by_id(file_id) if not file: - return json.dumps({'error': 'File not found'}) + return JSONCodec.dumps({'error': 'File not found'}) - if not await _has_read_access_to_file(file, user_id, user_role, __model_knowledge__): - return json.dumps({'error': 'File not found'}) + if not await _has_read_access_to_file(file, __user__, __model_knowledge__): + return JSONCodec.dumps({'error': 'File not found'}) content = '' if file.data: @@ -2669,7 +2805,7 @@ async def view_file( if is_truncated: result['truncated'] = True result['next_start_line'] = e + 1 - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) sliced = content[offset : offset + max_chars] is_truncated = (offset + len(sliced)) < total_chars @@ -2695,10 +2831,10 @@ async def view_file( if is_truncated: result['next_offset'] = offset + len(sliced) - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'view_file error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def view_knowledge_file( @@ -2723,10 +2859,10 @@ async def view_knowledge_file( :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) # Coerce parameters from LLM tool calls (may come as strings) if isinstance(offset, str): @@ -2755,7 +2891,7 @@ async def view_knowledge_file( file = await Files.get_file_by_id(file_id) if not file: - return json.dumps({'error': 'File not found'}) + return JSONCodec.dumps({'error': 'File not found'}) # Check access via any KB containing this file knowledges = await Knowledges.get_knowledges_by_file_id(file_id) @@ -2780,7 +2916,7 @@ async def view_knowledge_file( if not has_knowledge_access: if file.user_id != user_id and user_role != 'admin': - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) content = '' if file.data: @@ -2812,7 +2948,7 @@ async def view_knowledge_file( if is_truncated: result['truncated'] = True result['next_start_line'] = e + 1 - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) sliced = content[offset : offset + max_chars] is_truncated = (offset + len(sliced)) < total_chars @@ -2841,10 +2977,10 @@ async def view_knowledge_file( if is_truncated: result['next_offset'] = offset + len(sliced) - return json.dumps(result, ensure_ascii=False) + return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'view_knowledge_file error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def list_knowledge( @@ -2869,13 +3005,13 @@ async def list_knowledge( :return: JSON with knowledge_bases, files, and notes attached to this model """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) if not __model_knowledge__: - return json.dumps({'knowledge_bases': [], 'files': [], 'notes': []}) + return JSONCodec.dumps({'knowledge_bases': [], 'files': [], 'notes': []}) # Coerce parameters from LLM tool calls (may come as strings) if isinstance(skip, str): @@ -2976,7 +3112,7 @@ async def list_knowledge( } ) - return json.dumps( + return JSONCodec.dumps( { 'knowledge_bases': knowledge_bases, 'files': files, @@ -2986,7 +3122,7 @@ async def list_knowledge( ) except Exception as e: log.exception(f'list_knowledge error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def query_knowledge_files( @@ -3008,10 +3144,10 @@ async def query_knowledge_files( :return: JSON with relevant chunks containing content, source filename, and relevance score """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) # Coerce parameters from LLM tool calls (may come as strings) if isinstance(count, str): @@ -3027,8 +3163,8 @@ async def query_knowledge_files( else: # Try to parse as JSON array if it looks like one try: - knowledge_ids = json.loads(knowledge_ids) - except json.JSONDecodeError: + knowledge_ids = JSONCodec.loads(knowledge_ids) + except JSONCodec.JSONDecodeError: # Treat as single ID knowledge_ids = [knowledge_ids] @@ -3046,8 +3182,8 @@ async def query_knowledge_files( embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None) if not embedding_function: - return json.dumps({'error': 'Embedding function not configured'}) - user_model = UserModel.model_construct(id=user_id, role=user_role) + return JSONCodec.dumps({'error': 'Embedding function not configured'}) + user_model = UserModel(**__user__) collection_names = [] external_knowledges = [] @@ -3202,10 +3338,10 @@ async def query_knowledge_files( # Limit to requested count chunks = chunks[:count] - return json.dumps(chunks, ensure_ascii=False) + return JSONCodec.dumps(chunks, ensure_ascii=False) except Exception as e: log.exception(f'query_knowledge_files error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def query_knowledge_bases( @@ -3224,10 +3360,10 @@ async def query_knowledge_bases( :return: JSON with matching KBs (id, name, description, similarity) """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: import heapq @@ -3240,8 +3376,8 @@ async def query_knowledge_bases( user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None) if not embedding_function: - return json.dumps({'error': 'Embedding function not configured'}) - user_model = UserModel.model_construct(id=user_id, role=__user__.get('role', 'user')) + return JSONCodec.dumps({'error': 'Embedding function not configured'}) + user_model = UserModel(**__user__) query_embedding = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user_model) # Min-heap of (distance, knowledge_base_id) - only holds top `count` results @@ -3306,11 +3442,11 @@ async def query_knowledge_bases( } ) - return json.dumps(matching_knowledge_bases, ensure_ascii=False) + return JSONCodec.dumps(matching_knowledge_bases, ensure_ascii=False) except Exception as e: log.exception(f'query_knowledge_bases error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -3331,10 +3467,10 @@ async def view_skill( :return: The full skill instructions as markdown content """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.access_grants import AccessGrants @@ -3346,7 +3482,7 @@ async def view_skill( skill = await Skills.get_skill_by_id(id.lower()) if not skill or not skill.is_active: - return json.dumps({'error': f"Skill '{id}' not found"}) + return JSONCodec.dumps({'error': f"Skill '{id}' not found"}) # Check user access user_role = __user__.get('role', 'user') @@ -3359,9 +3495,9 @@ async def view_skill( permission='read', user_group_ids=set(user_group_ids), ): - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) - return json.dumps( + return JSONCodec.dumps( { 'name': skill.name, 'content': skill.content, @@ -3370,7 +3506,7 @@ async def view_skill( ) except Exception as e: log.exception(f'view_skill error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -3433,7 +3569,7 @@ async def create_tasks( :return: JSON with the full task list and summary counts """ if not is_saved_chat_id(__chat_id__): - return json.dumps({'error': 'Saved chat context not available'}) + return JSONCodec.dumps({'error': 'Saved chat context not available'}) try: all_tasks = [] @@ -3459,13 +3595,13 @@ async def create_tasks( await Chats.update_chat_tasks_by_id(__chat_id__, all_tasks) await _emit_tasks(__event_emitter__, all_tasks) - return json.dumps( + return JSONCodec.dumps( {'tasks': all_tasks, 'summary': _task_summary(all_tasks)}, ensure_ascii=False, ) except Exception as e: log.exception(f'tasks error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def update_task( @@ -3485,12 +3621,12 @@ async def update_task( :return: JSON with the updated task list and summary counts """ if not is_saved_chat_id(__chat_id__): - return json.dumps({'error': 'Saved chat context not available'}) + return JSONCodec.dumps({'error': 'Saved chat context not available'}) try: status = status.strip().lower() if status not in VALID_TASK_STATUSES: - return json.dumps( + return JSONCodec.dumps( {'error': f'Invalid status: {status}. Must be one of: {", ".join(sorted(VALID_TASK_STATUSES))}'} ) @@ -3504,18 +3640,18 @@ async def update_task( break if not found: - return json.dumps({'error': f'Task with id "{id}" not found'}) + return JSONCodec.dumps({'error': f'Task with id "{id}" not found'}) await Chats.update_chat_tasks_by_id(__chat_id__, all_tasks) await _emit_tasks(__event_emitter__, all_tasks) - return json.dumps( + return JSONCodec.dumps( {'tasks': all_tasks, 'summary': _task_summary(all_tasks)}, ensure_ascii=False, ) except Exception as e: log.exception(f'update_task_status error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -3565,13 +3701,13 @@ async def create_automation( :return: JSON with the created automation details including id, next scheduled runs """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: - from open_webui.models.automations import AutomationData, AutomationForm, Automations + from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations from open_webui.models.users import Users from open_webui.routers.automations import check_automation_limits from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule @@ -3579,7 +3715,7 @@ async def create_automation( user_id = __user__.get('id') user = await Users.get_user_by_id(user_id) if not user: - return json.dumps({'error': 'User not found'}) + return JSONCodec.dumps({'error': 'User not found'}) # Fall back to model dict ID since __metadata__ may predate model_id assignment metadata = __metadata__ or {} @@ -3587,23 +3723,23 @@ async def create_automation( metadata.get('model', {}).get('id') if isinstance(metadata.get('model'), dict) else None ) if not model_id: - return json.dumps({'error': 'Could not detect current model'}) + return JSONCodec.dumps({'error': 'Could not detect current model'}) try: folder_id = await _validate_owned_automation_folder(user_id, folder_id) except ValueError as e: - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # Validate the RRULE try: validate_rrule(rrule, tz=user.timezone) except ValueError as e: - return json.dumps({'error': f'Invalid schedule: {e}'}) + return JSONCodec.dumps({'error': f'Invalid schedule: {e}'}) try: await check_automation_limits(__request__, user, rrule, None, is_create=True) except HTTPException as e: - return json.dumps({'error': e.detail}) + return JSONCodec.dumps({'error': e.detail}) tz = user.timezone form = AutomationForm( @@ -3613,19 +3749,25 @@ async def create_automation( prompt=prompt, model_id=model_id, rrule=rrule, + target=( + AutomationTarget(type='channel', channel_id=metadata.get('chat_id', '').removeprefix('channel:')) + if metadata.get('chat_id', '').startswith('channel:') + else None + ), ), is_active=True, ) automation = await Automations.insert(user_id, form, next_run_ns(rrule, tz=tz)) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'id': automation.id, 'name': automation.name, 'folder_id': automation.folder_id, 'model_id': model_id, + 'target': automation.data.get('target'), 'is_active': automation.is_active, 'next_runs': next_n_runs_ns(rrule, tz=tz), }, @@ -3633,7 +3775,7 @@ async def create_automation( ) except Exception as e: log.exception(f'create_automation error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def update_automation( @@ -3642,7 +3784,7 @@ async def update_automation( prompt: Optional[str] = None, rrule: Optional[str] = None, model_id: Optional[str] = None, - folder_id: Optional[str] = None, + folder_id: Optional[str] = '', __request__: Request = None, __user__: dict = None, ) -> str: @@ -3653,18 +3795,18 @@ async def update_automation( :param name: New name for the automation (optional) :param prompt: New prompt/instructions (optional) :param rrule: New iCalendar RRULE schedule string (optional). See create_automation for format examples. - :param model_id: New model ID to use (optional) - :param folder_id: New owner-owned folder ID (optional); pass an empty string to clear + :param model_id: New model ID to use (optional); blank values are ignored + :param folder_id: New owner-owned folder ID (optional); omit or pass blank to keep unchanged, pass null to clear :return: JSON with the updated automation details """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: - from open_webui.models.automations import AutomationData, AutomationForm, Automations + from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations from open_webui.models.users import Users from open_webui.routers.automations import check_automation_limits from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule @@ -3672,38 +3814,40 @@ async def update_automation( user_id = __user__.get('id') user = await Users.get_user_by_id(user_id) if not user: - return json.dumps({'error': 'User not found'}) + return JSONCodec.dumps({'error': 'User not found'}) automation = await Automations.get_by_id(automation_id) if not automation: - return json.dumps({'error': 'Automation not found'}) + return JSONCodec.dumps({'error': 'Automation not found'}) if automation.user_id != user_id: - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) # Merge provided fields with existing values new_name = name if name is not None else automation.name new_prompt = prompt if prompt is not None else automation.data.get('prompt', '') - new_model_id = model_id if model_id is not None else automation.data.get('model_id', '') + new_model_id = model_id.strip() if model_id and model_id.strip() else automation.data.get('model_id', '') new_rrule = rrule if rrule is not None else automation.data.get('rrule', '') if folder_id is None: + new_folder_id = None + elif not folder_id.strip(): new_folder_id = automation.folder_id else: try: - new_folder_id = await _validate_owned_automation_folder(user_id, folder_id) + new_folder_id = await _validate_owned_automation_folder(user_id, folder_id.strip()) except ValueError as e: - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # Validate RRULE if changed if rrule is not None: try: validate_rrule(new_rrule, tz=user.timezone) except ValueError as e: - return json.dumps({'error': f'Invalid schedule: {e}'}) + return JSONCodec.dumps({'error': f'Invalid schedule: {e}'}) try: await check_automation_limits(__request__, user, new_rrule, None) except HTTPException as e: - return json.dumps({'error': e.detail}) + return JSONCodec.dumps({'error': e.detail}) tz = user.timezone form = AutomationForm( @@ -3713,19 +3857,21 @@ async def update_automation( prompt=new_prompt, model_id=new_model_id, rrule=new_rrule, + target=AutomationTarget(**automation.data['target']) if automation.data.get('target') else None, ), is_active=automation.is_active, ) updated = await Automations.update_by_id(automation_id, form, next_run_ns(new_rrule, tz=tz)) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'id': updated.id, 'name': updated.name, 'folder_id': updated.folder_id, 'model_id': new_model_id, + 'target': updated.data.get('target'), 'is_active': updated.is_active, 'next_runs': next_n_runs_ns(new_rrule, tz=tz), }, @@ -3733,7 +3879,7 @@ async def update_automation( ) except Exception as e: log.exception(f'update_automation error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def list_automations( @@ -3752,10 +3898,10 @@ async def list_automations( :return: JSON list of automations with id, name, prompt snippet, schedule, status, and next runs """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.automations import Automations @@ -3768,7 +3914,7 @@ async def list_automations( try: folder_id = await _validate_owned_automation_folder(user_id, folder_id) except ValueError as e: - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) result = await Automations.search_automations( user_id=user_id, @@ -3791,6 +3937,7 @@ async def list_automations( 'folder_id': item.folder_id, 'prompt_snippet': snippet, 'model_id': item.data.get('model_id', ''), + 'target': item.data.get('target'), 'rrule': rrule, 'is_active': item.is_active, 'last_run_at': item.last_run_at, @@ -3798,13 +3945,13 @@ async def list_automations( } ) - return json.dumps( + return JSONCodec.dumps( {'automations': automations, 'total': result.total}, ensure_ascii=False, ) except Exception as e: log.exception(f'list_automations error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def toggle_automation( @@ -3819,10 +3966,10 @@ async def toggle_automation( :return: JSON with the updated automation status """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.automations import Automations @@ -3834,9 +3981,9 @@ async def toggle_automation( automation = await Automations.get_by_id(automation_id) if not automation: - return json.dumps({'error': 'Automation not found'}) + return JSONCodec.dumps({'error': 'Automation not found'}) if automation.user_id != user_id: - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) rrule = automation.data.get('rrule', '') toggled = await Automations.toggle( @@ -3844,7 +3991,7 @@ async def toggle_automation( next_run_ns(rrule, tz=user.timezone if user else None), ) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'id': toggled.id, @@ -3855,7 +4002,7 @@ async def toggle_automation( ) except Exception as e: log.exception(f'toggle_automation error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def delete_automation( @@ -3870,10 +4017,10 @@ async def delete_automation( :return: JSON confirming the automation was deleted """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.automations import AutomationRuns, Automations @@ -3882,15 +4029,15 @@ async def delete_automation( automation = await Automations.get_by_id(automation_id) if not automation: - return json.dumps({'error': 'Automation not found'}) + return JSONCodec.dumps({'error': 'Automation not found'}) if automation.user_id != user_id: - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) name = automation.name await AutomationRuns.delete_by_automation(automation_id) await Automations.delete(automation_id) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'message': f'Automation "{name}" deleted', @@ -3899,7 +4046,7 @@ async def delete_automation( ) except Exception as e: log.exception(f'delete_automation error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) # ============================================================================= @@ -3907,6 +4054,9 @@ async def delete_automation( # ============================================================================= +MAX_CALENDAR_RANGE_END_NS = 2**63 - 1 + + def _get_user_tz(user_dict: dict): """Get the user's timezone as a ZoneInfo, falling back to UTC.""" from zoneinfo import ZoneInfo @@ -3981,10 +4131,10 @@ async def search_calendar_events( :return: JSON list of matching events with id, title, description, start, end, calendar_id, location """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.calendar import CalendarEvents @@ -4003,16 +4153,12 @@ async def search_calendar_events( try: start_ns = _dt_to_ns(start, tz) if start else 0 except (ValueError, TypeError) as e: - return json.dumps({'error': f'Invalid start datetime: {e}'}) + return JSONCodec.dumps({'error': f'Invalid start datetime: {e}'}) try: - end_ns = ( - _dt_to_ns(end, tz) - if end - else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000 - ) + end_ns = _dt_to_ns(end, tz) if end else MAX_CALENDAR_RANGE_END_NS except (ValueError, TypeError) as e: - return json.dumps({'error': f'Invalid end datetime: {e}'}) + return JSONCodec.dumps({'error': f'Invalid end datetime: {e}'}) items = await CalendarEvents.get_events_by_range( user_id=user_id, @@ -4032,7 +4178,7 @@ async def search_calendar_events( ] events = [_event_to_dict(item, tz) for item in items[:count]] - return json.dumps( + return JSONCodec.dumps( {'events': events, 'total': len(items)}, ensure_ascii=False, ) @@ -4046,13 +4192,13 @@ async def search_calendar_events( ) events = [_event_to_dict(item, tz) for item in result.items] - return json.dumps( + return JSONCodec.dumps( {'events': events, 'total': result.total}, ensure_ascii=False, ) except Exception as e: log.exception(f'search_calendar_events error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def create_calendar_event( @@ -4084,10 +4230,10 @@ async def create_calendar_event( :return: JSON with the created event details including id """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.calendar import CalendarEventForm, CalendarEvents, Calendars @@ -4101,13 +4247,13 @@ async def create_calendar_event( if not default_cal and calendars: default_cal = calendars[0] if not default_cal: - return json.dumps({'error': 'No calendars found. Cannot create event.'}) + return JSONCodec.dumps({'error': 'No calendars found. Cannot create event.'}) calendar_id = default_cal.id # Verify access cal = await Calendars.get_calendar_by_id(calendar_id) if not cal: - return json.dumps({'error': 'Calendar not found'}) + return JSONCodec.dumps({'error': 'Calendar not found'}) if cal.user_id != user_id and __user__.get('role') != 'admin': from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import Groups @@ -4120,7 +4266,7 @@ async def create_calendar_event( permission='write', user_group_ids=set(user_group_ids), ): - return json.dumps({'error': 'Access denied to this calendar'}) + return JSONCodec.dumps({'error': 'Access denied to this calendar'}) # Coerce boolean from LLM if isinstance(all_day, str): @@ -4131,14 +4277,14 @@ async def create_calendar_event( try: start_ns = _dt_to_ns(start, tz) except (ValueError, TypeError) as e: - return json.dumps({'error': f'Invalid start datetime: {e}. Use format like "2026-04-20 09:00"'}) + return JSONCodec.dumps({'error': f'Invalid start datetime: {e}. Use format like "2026-04-20 09:00"'}) end_ns = None if end: try: end_ns = _dt_to_ns(end, tz) except (ValueError, TypeError) as e: - return json.dumps({'error': f'Invalid end datetime: {e}. Use format like "2026-04-20 10:00"'}) + return JSONCodec.dumps({'error': f'Invalid end datetime: {e}. Use format like "2026-04-20 10:00"'}) elif not all_day: # Default to 1 hour duration end_ns = start_ns + 3_600_000_000_000 @@ -4168,9 +4314,9 @@ async def create_calendar_event( event = await CalendarEvents.insert_new_event(user_id, form) if not event: - return json.dumps({'error': 'Failed to create event'}) + return JSONCodec.dumps({'error': 'Failed to create event'}) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', **_event_to_dict(event, tz), @@ -4179,7 +4325,7 @@ async def create_calendar_event( ) except Exception as e: log.exception(f'create_calendar_event error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def update_calendar_event( @@ -4211,10 +4357,10 @@ async def update_calendar_event( :return: JSON with the updated event details """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.access_grants import AccessGrants @@ -4225,13 +4371,13 @@ async def update_calendar_event( event = await CalendarEvents.get_event_by_id(event_id) if not event: - return json.dumps({'error': 'Event not found'}) + return JSONCodec.dumps({'error': 'Event not found'}) # Check write access to the event's calendar if event.user_id != user_id and __user__.get('role') != 'admin': cal = await Calendars.get_calendar_by_id(event.calendar_id) if not cal: - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] if not await AccessGrants.has_access( user_id=user_id, @@ -4240,7 +4386,7 @@ async def update_calendar_event( permission='write', user_group_ids=set(user_group_ids), ): - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) # Coerce boolean strings from LLM if isinstance(all_day, str): @@ -4255,14 +4401,14 @@ async def update_calendar_event( try: start_ns = _dt_to_ns(start, tz) except (ValueError, TypeError) as e: - return json.dumps({'error': f'Invalid start datetime: {e}'}) + return JSONCodec.dumps({'error': f'Invalid start datetime: {e}'}) end_ns = None if end is not None: try: end_ns = _dt_to_ns(end, tz) except (ValueError, TypeError) as e: - return json.dumps({'error': f'Invalid end datetime: {e}'}) + return JSONCodec.dumps({'error': f'Invalid end datetime: {e}'}) # Build meta update with reminder setting if provided meta = None @@ -4275,22 +4421,23 @@ async def update_calendar_event( if reminder_minutes is not None: meta = {'alert_minutes': reminder_minutes} - form = CalendarEventUpdateForm( - title=title, - description=description, - start_at=start_ns, - end_at=end_ns, - all_day=all_day, - location=location, - is_cancelled=is_cancelled, - meta=meta, - ) + update_fields = { + 'title': title, + 'description': description, + 'start_at': start_ns, + 'end_at': end_ns, + 'all_day': all_day, + 'location': location, + 'is_cancelled': is_cancelled, + 'meta': meta, + } + form = CalendarEventUpdateForm(**{k: v for k, v in update_fields.items() if v is not None}) updated = await CalendarEvents.update_event_by_id(event_id, form) if not updated: - return json.dumps({'error': 'Failed to update event'}) + return JSONCodec.dumps({'error': 'Failed to update event'}) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', **_event_to_dict(updated, tz), @@ -4299,7 +4446,7 @@ async def update_calendar_event( ) except Exception as e: log.exception(f'update_calendar_event error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) async def delete_calendar_event( @@ -4314,10 +4461,10 @@ async def delete_calendar_event( :return: JSON confirming the event was deleted """ if __request__ is None: - return json.dumps({'error': 'Request context not available'}) + return JSONCodec.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({'error': 'User context not available'}) + return JSONCodec.dumps({'error': 'User context not available'}) try: from open_webui.models.access_grants import AccessGrants @@ -4328,13 +4475,13 @@ async def delete_calendar_event( event = await CalendarEvents.get_event_by_id(event_id) if not event: - return json.dumps({'error': 'Event not found'}) + return JSONCodec.dumps({'error': 'Event not found'}) # Check write access if event.user_id != user_id and __user__.get('role') != 'admin': cal = await Calendars.get_calendar_by_id(event.calendar_id) if not cal: - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] if not await AccessGrants.has_access( user_id=user_id, @@ -4343,14 +4490,14 @@ async def delete_calendar_event( permission='write', user_group_ids=set(user_group_ids), ): - return json.dumps({'error': 'Access denied'}) + return JSONCodec.dumps({'error': 'Access denied'}) title = event.title result = await CalendarEvents.delete_event_by_id(event_id) if not result: - return json.dumps({'error': 'Failed to delete event'}) + return JSONCodec.dumps({'error': 'Failed to delete event'}) - return json.dumps( + return JSONCodec.dumps( { 'status': 'success', 'message': f'Event "{title}" deleted', @@ -4359,4 +4506,4 @@ async def delete_calendar_event( ) except Exception as e: log.exception(f'delete_calendar_event error: {e}') - return json.dumps({'error': str(e)}) + return JSONCodec.dumps({'error': str(e)}) diff --git a/backend/open_webui/tools/knowledge_fs.py b/backend/open_webui/tools/knowledge_fs.py index eed252ff2b..833f2fed89 100644 --- a/backend/open_webui/tools/knowledge_fs.py +++ b/backend/open_webui/tools/knowledge_fs.py @@ -8,7 +8,6 @@ Re-exported through builtin.py for consistent imports. """ import contextvars -import json import logging import re import shlex @@ -33,6 +32,9 @@ DEFAULT_TAIL_LINES = 10 # Matching time allowed per tool call. Backtracking cost is exponential in the length of the # matched text, so capping the pattern or the line does not bound it. MATCH_BUDGET_SECONDS = 2.0 +MAX_REGEX_QUANTIFIER_COUNT = 2_000 +MAX_REGEX_QUANTIFIER_EXPANSION = 100_000 +_COUNTED_QUANTIFIER_RE = re.compile(r'(? str: return pattern.replace('\\|', '|').replace('\|', '|') +def validate_regex_quantifiers(pattern: str) -> str | None: + """Reject counted quantifiers that make regex compilation expand too much.""" + quantifier_expansion = 1 + for quantifier in _COUNTED_QUANTIFIER_RE.finditer(pattern): + count_text = quantifier.group(1) + count = int(count_text) if len(count_text) <= 6 else MAX_REGEX_QUANTIFIER_COUNT + 1 + if count > MAX_REGEX_QUANTIFIER_COUNT: + return f'Regex quantifier counts over {MAX_REGEX_QUANTIFIER_COUNT:g} are not supported' + + # ponytail: conservative expansion catches nested quantifier bombs without mirroring regex syntax. + quantifier_expansion *= max(count, 1) + if quantifier_expansion > MAX_REGEX_QUANTIFIER_EXPANSION: + return 'Regex quantifiers expand too much, lower the counts' + + return None + + def build_matcher(pattern: str, case_insensitive: bool = False, use_regex: bool = False) -> tuple: """Build a matcher function. Returns (match_fn, error_str_or_None).""" if not use_regex and is_regex_pattern(pattern): @@ -92,6 +111,9 @@ def build_matcher(pattern: str, case_insensitive: bool = False, use_regex: bool if use_regex: normalized = normalize_regex(pattern) + quantifier_error = validate_regex_quantifiers(normalized) + if quantifier_error: + return None, quantifier_error try: re_flags = regex.IGNORECASE if case_insensitive else 0 compiled = regex.compile(normalized, re_flags) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index a549dae0a0..2f8c89a1d6 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -1,4 +1,3 @@ -import json from typing import Any from open_webui.config import DEFAULT_USER_PERMISSIONS @@ -12,6 +11,7 @@ from open_webui.models.access_grants import ( ) from open_webui.models.groups import Groups from open_webui.models.users import UserModel +from open_webui.utils.json_codec import JSONCodec from sqlalchemy.ext.asyncio import AsyncSession @@ -57,7 +57,7 @@ async def get_permissions( user_groups = await Groups.get_groups_by_member_id(user_id, db=db) # Deep copy default permissions to avoid modifying the original dict - permissions = json.loads(json.dumps(default_permissions)) + permissions = JSONCodec.loads(JSONCodec.dumps(default_permissions)) # Combine permissions from all user groups for group in user_groups: diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index f1e4569cbb..efc8b11222 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -34,7 +34,7 @@ async def has_access_to_file( file.user_id == user.id separately before calling this. """ file = await Files.get_file_by_id(file_id, db=db) - log.debug(f'Checking if user has {access_type} access to file') + log.debug('Checking if user has %s access to file', access_type) if not file: return False @@ -106,22 +106,29 @@ async def has_access_to_file( # Check if the file is directly attached to a shared workspace model (per the ownership # note above, model write is conferred only for files the model owner owns). - for model in await Models.get_models_by_user_id( - user.id, permission=access_type, db=db, user_group_ids=user_group_ids - ): - knowledge_items = getattr(model.meta, 'knowledge', None) or [] - for item in knowledge_items: - if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: - if access_type == 'read' or model.user_id == file.user_id: - return True + model_owners = await Models.get_model_owners_attaching_file(file.id, db=db) + if access_type != 'read': + model_owners = {model_id: owner_id for model_id, owner_id in model_owners.items() if owner_id == file.user_id} + if user.id in model_owners.values(): + return True - return False + return bool( + await AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type='model', + resource_ids=list(model_owners), + permission=access_type, + user_group_ids=user_group_ids, + db=db, + ) + ) async def get_accessible_folder_files( entries: list[dict] | None, user: UserModel, db: AsyncSession | None = None, + user_group_ids: set[str] | None = None, ) -> list[dict]: """Filter folder.data['files'] entries to those the caller can read. @@ -138,8 +145,8 @@ async def get_accessible_folder_files( if user.role == 'admin': return entries - # One group-membership fetch for the whole folder listing - user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + if user_group_ids is None: + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} accessible: list[dict] = [] for entry in entries: @@ -149,7 +156,9 @@ async def get_accessible_folder_files( if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids): accessible.append(entry) elif entry_type == 'collection': - if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db): + if await Knowledges.check_access_by_user_id( + entry_id, user.id, 'read', db=db, user_group_ids=user_group_ids + ): accessible.append(entry) elif entry_type == 'note': # Owner has no self-grant (notes are private by default), so check ownership too. @@ -163,6 +172,7 @@ async def get_accessible_folder_files( resource_type='note', resource_id=entry_id, permission='read', + user_group_ids=user_group_ids, db=db, ) ): diff --git a/backend/open_webui/utils/access_control/folders.py b/backend/open_webui/utils/access_control/folders.py index 7dcc1a3395..1a632ba01b 100644 --- a/backend/open_webui/utils/access_control/folders.py +++ b/backend/open_webui/utils/access_control/folders.py @@ -3,22 +3,32 @@ from open_webui.models.folders import FolderModel, Folders from sqlalchemy.ext.asyncio import AsyncSession -async def has_folder_access(user_id: str, folder: FolderModel, permission: str, db: AsyncSession) -> bool: +async def has_folder_access(user_id: str, folder: FolderModel, permission: str, db: AsyncSession | None) -> bool: """Check if user has access to folder directly or via ancestor inheritance.""" - if folder.user_id == user_id: - return True + # A corrupt parent loop must not spin forever + seen_ids = set() + while folder and folder.id not in seen_ids: + seen_ids.add(folder.id) - if await AccessGrants.has_access( - user_id=user_id, - resource_type='folder', - resource_id=folder.id, - permission=permission, - db=db, - ): - return True - # Check ancestor chain for inherited access - if folder.parent_id: - parent = await Folders.get_folder_by_id(folder.parent_id, db=db) - if parent: - return await has_folder_access(user_id, parent, permission, db) + if folder.user_id == user_id: + return True + + if await AccessGrants.has_access( + user_id=user_id, + resource_type='folder', + resource_id=folder.id, + permission=permission, + db=db, + ): + return True + + folder = await Folders.get_folder_by_id(folder.parent_id, db=db) if folder.parent_id else None return False + + +async def has_folder_write_access(user_id: str, folder_id: str, db: AsyncSession | None = None) -> bool: + """Check write access on the folder with this id; False if no such folder exists.""" + folder = await Folders.get_folder_by_id(folder_id, db=db) + if not folder: + return False + return await has_folder_access(user_id, folder, 'write', db) diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 5a289c2444..3d49643cec 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -1,4 +1,3 @@ -import json import logging import aiohttp @@ -9,9 +8,12 @@ from open_webui.env import ( ) from open_webui.models.users import UserModel from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) +ANTHROPIC_VERSION = '2023-06-01' + ANTHROPIC_CONVERTED_REQUEST_PARAMS = { 'model', 'messages', @@ -48,7 +50,7 @@ async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> di async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: headers = { 'x-api-key': key, - 'anthropic-version': '2023-06-01', + 'anthropic-version': ANTHROPIC_VERSION, } if ENABLE_FORWARD_USER_INFO_HEADERS and user: @@ -235,7 +237,7 @@ def convert_anthropic_to_openai_payload( 'function': { 'name': block.get('name', ''), 'arguments': ( - json.dumps(block.get('input', {})) + JSONCodec.dumps(block.get('input', {})) if isinstance(block.get('input'), dict) else str(block.get('input', '{}')) ), @@ -531,8 +533,8 @@ def convert_openai_to_anthropic_response( for tool_call in tool_calls: function = tool_call.get('function', {}) try: - tool_input = json.loads(function.get('arguments', '{}')) - except (json.JSONDecodeError, TypeError): + tool_input = JSONCodec.loads(function.get('arguments', '{}')) + except (JSONCodec.JSONDecodeError, TypeError): tool_input = {} content.append( { @@ -643,7 +645,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'usage': {'input_tokens': input_tokens or 0, 'output_tokens': 0}, }, } - yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode() + yield f'event: message_start\ndata: {JSONCodec.dumps(message_start)}\n\n'.encode() try: async for chunk in openai_stream_generator: @@ -663,8 +665,8 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str continue try: - data = json.loads(data_string) - except (json.JSONDecodeError, TypeError): + data = JSONCodec.loads(data_string) + except (JSONCodec.JSONDecodeError, TypeError): continue usage_data = data.get('usage') @@ -730,7 +732,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'index': current_block_index, 'content_block': {'type': 'thinking', 'thinking': ''}, } - yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode() + yield f'event: content_block_start\ndata: {JSONCodec.dumps(block_start)}\n\n'.encode() thinking_block_open = True block_delta = { @@ -738,7 +740,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'index': current_block_index, 'delta': {'type': 'thinking_delta', 'thinking': reasoning_content}, } - yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode() + yield f'event: content_block_delta\ndata: {JSONCodec.dumps(block_delta)}\n\n'.encode() # --- Handle text content --- # Anthropic expects text blocks before tool blocks, so skip @@ -750,7 +752,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'type': 'content_block_stop', 'index': current_block_index, } - yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + yield f'event: content_block_stop\ndata: {JSONCodec.dumps(block_stop)}\n\n'.encode() thinking_block_open = False current_block_index += 1 @@ -760,7 +762,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'index': current_block_index, 'content_block': {'type': 'text', 'text': ''}, } - yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode() + yield f'event: content_block_start\ndata: {JSONCodec.dumps(block_start)}\n\n'.encode() text_block_open = True block_delta = { @@ -768,7 +770,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'index': current_block_index, 'delta': {'type': 'text_delta', 'text': content}, } - yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode() + yield f'event: content_block_delta\ndata: {JSONCodec.dumps(block_delta)}\n\n'.encode() # --- Handle tool calls --- # Some providers put tool_calls on the final message object @@ -784,7 +786,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'type': 'content_block_stop', 'index': current_block_index, } - yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + yield f'event: content_block_stop\ndata: {JSONCodec.dumps(block_stop)}\n\n'.encode() thinking_block_open = False current_block_index += 1 @@ -793,7 +795,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'type': 'content_block_stop', 'index': current_block_index, } - yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + yield f'event: content_block_stop\ndata: {JSONCodec.dumps(block_stop)}\n\n'.encode() text_block_open = False current_block_index += 1 @@ -856,7 +858,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'input': {}, }, } - yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode() + yield f'event: content_block_start\ndata: {JSONCodec.dumps(block_start)}\n\n'.encode() current_block_index += 1 # Buffer arguments and emit as input_json_delta @@ -872,19 +874,19 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'partial_json': arguments_chunk, }, } - yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode() + yield f'event: content_block_delta\ndata: {JSONCodec.dumps(block_delta)}\n\n'.encode() # Close the block once arguments form complete JSON if tool['started'] and not tool['stopped']: try: - json.loads(tool['arguments']) + JSONCodec.loads(tool['arguments']) tool['stopped'] = True block_stop = { 'type': 'content_block_stop', 'index': tool['block_index'], } - yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() - except (json.JSONDecodeError, ValueError): + yield f'event: content_block_stop\ndata: {JSONCodec.dumps(block_stop)}\n\n'.encode() + except (JSONCodec.JSONDecodeError, ValueError): pass # --- Handle finish reason --- @@ -902,7 +904,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str # Close any open thinking block if thinking_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() + yield f'event: content_block_stop\ndata: {JSONCodec.dumps(block_stop)}\n\n'.encode() current_block_index += 1 # Flush any tools that buffered arguments but never emitted a block @@ -921,7 +923,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'input': {}, }, } - yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode() + yield f'event: content_block_start\ndata: {JSONCodec.dumps(block_start)}\n\n'.encode() current_block_index += 1 if tool['arguments']: @@ -933,18 +935,18 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'partial_json': tool['arguments'], }, } - yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode() + yield f'event: content_block_delta\ndata: {JSONCodec.dumps(block_delta)}\n\n'.encode() # 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() + yield f'event: content_block_stop\ndata: {JSONCodec.dumps(block_stop)}\n\n'.encode() # Close any tool call blocks that are still open for tool in tracked_tool_calls.values(): if tool['started'] and not tool['stopped']: block_stop = {'type': 'content_block_stop', 'index': tool['block_index']} - yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + yield f'event: content_block_stop\ndata: {JSONCodec.dumps(block_stop)}\n\n'.encode() # Emit message_delta with stop reason usage = {'output_tokens': output_tokens} @@ -969,7 +971,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str }, 'usage': usage, } - yield f'event: message_delta\ndata: {json.dumps(message_delta)}\n\n'.encode() + yield f'event: message_delta\ndata: {JSONCodec.dumps(message_delta)}\n\n'.encode() # Emit message_stop - yield f'event: message_stop\ndata: {json.dumps({"type": "message_stop"})}\n\n'.encode() + yield f'event: message_stop\ndata: {JSONCodec.dumps({"type": "message_stop"})}\n\n'.encode() diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py index d28abc90f8..2d9f4e190e 100644 --- a/backend/open_webui/utils/asgi_middleware.py +++ b/backend/open_webui/utils/asgi_middleware.py @@ -40,6 +40,7 @@ from fastapi.security import HTTPAuthorizationCredentials from open_webui.env import CUSTOM_API_KEY_HEADER from open_webui.internal.db import ScopedSession from open_webui.utils.auth import get_http_authorization_cred +from open_webui.utils.security_headers import set_security_headers from starlette.datastructures import MutableHeaders from starlette.requests import Request from starlette.types import ASGIApp, Message, Receive, Scope, Send @@ -47,9 +48,17 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send log = logging.getLogger(__name__) -class CommitSessionMiddleware: - """Commit and release the thread-local sync `ScopedSession` after each - HTTP request. +class AppHTTPMiddleware: + """Open WebUI's pure-ASGI HTTP middleware. + + Keeps the app's request-wide behavior in one middleware layer without + hiding the old concerns behind a stack of wrappers: + + * reject malformed `/ws/socket.io` upgrade requests + * stash bearer/cookie/API-key credentials on `request.state.token` + * stamp `X-Process-Time` and configured security headers + * serve the legacy `/watch` and `?shared=` redirects + * commit and release the thread-local sync `ScopedSession` Most requests now use the async session; the sync ScopedSession is only touched by startup, healthchecks, and a handful of legacy @@ -81,156 +90,80 @@ class CommitSessionMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app + # Headers derive only from env vars, which are static for the process + # lifetime — compute them once instead of per response. + self._security_headers = list(set_security_headers().items()) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope['type'] != 'http': await self.app(scope, receive, send) return - path = scope.get('path', '') - # Keep health probes independent from sync session commit/remove - # so DB pressure cannot delay or fail probe responses. - if path in {'/health', '/ready', '/health/db'}: - await self.app(scope, receive, send) - return - - try: - await self.app(scope, receive, send) - except BaseException: - # Downstream did not complete successfully. Roll back any - # pending sync writes, release the connection, and let the - # exception propagate. - if ScopedSession.registry.has(): - try: - ScopedSession.rollback() - except Exception: - log.exception('CommitSessionMiddleware: rollback failed after downstream error') - finally: - ScopedSession.remove() - raise - - # Nothing in this request touched the sync session: committing would - # only instantiate one to run an empty transaction. - if not ScopedSession.registry.has(): - return - - # Downstream completed. Commit pending sync work. - try: - ScopedSession.commit() - except Exception: - log.exception('CommitSessionMiddleware: post-request commit failed; response was already sent to client') - try: - ScopedSession.rollback() - except Exception: - log.exception('CommitSessionMiddleware: rollback failed after commit failure') - raise - finally: - # CRITICAL: remove() returns the connection to the pool. - # Without this, connections remain "checked out" and - # accumulate as "idle in transaction" in PostgreSQL. - ScopedSession.remove() - - -class AuthTokenMiddleware: - """Extract the bearer/cookie/API-key credential and stash it on - `request.state.token`. - - The header used for API-key transport is controlled by the - ``CUSTOM_API_KEY_HEADER`` environment variable (default ``x-api-key``). - This is useful when Open WebUI sits behind a reverse proxy that - consumes the ``Authorization`` header for its own authentication — - set the env var to a unique header (e.g. ``X-OpenWebUI-Key``) so - the middleware checks that instead and avoids the 401 short-circuit. - - Routes that depend on `get_verified_user` etc. read this state. - Also stamps an `X-Process-Time` response header. - """ - - def __init__(self, app: ASGIApp, *, fastapi_app) -> None: - self.app = app - self._fastapi_app = fastapi_app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope['type'] != 'http': - await self.app(scope, receive, send) + if await self._reject_invalid_websocket(scope, receive, send): return start_time = time.monotonic() request = Request(scope) + self._set_token(request) + send_with_headers = self._send_with_headers(send, start_time) + try: + if await self._redirect_legacy_url(scope, receive, send_with_headers): + pass + # Keep health probes independent from sync session commit/remove so DB + # pressure cannot delay or fail probe responses. + elif scope.get('path', '') in {'/health', '/ready', '/health/db'}: + await self.app(scope, receive, send_with_headers) + return + else: + await self.app(scope, receive, send_with_headers) + except BaseException: + self._rollback_session('AppHTTPMiddleware: rollback failed after downstream error') + raise + + self._commit_session() + + def _set_token(self, request: Request) -> None: token = get_http_authorization_cred(request.headers.get('Authorization')) - if token is None: - cookie_token = request.cookies.get('token') - if cookie_token: - token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=cookie_token) - if token is None: - api_key = request.headers.get(CUSTOM_API_KEY_HEADER) - if api_key: - token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key) - + if token is None and (cookie_token := request.cookies.get('token')): + token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=cookie_token) + if token is None and (api_key := request.headers.get(CUSTOM_API_KEY_HEADER)): + token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key) request.state.token = token - async def send_with_timing(message: Message) -> None: + def _send_with_headers(self, send: Send, start_time: float) -> Send: + async def send_with_headers(message: Message) -> None: if message['type'] == 'http.response.start': - process_time = time.monotonic() - start_time headers = MutableHeaders(scope=message) - headers['X-Process-Time'] = f'{process_time:.6f}' + headers['X-Process-Time'] = f'{time.monotonic() - start_time:.6f}' + for key, value in self._security_headers: + headers[key] = value await send(message) - await self.app(scope, receive, send_with_timing) - - -class WebsocketUpgradeGuardMiddleware: - """Reject HTTP requests to `/ws/socket.io` that claim - `transport=websocket` but lack the proper `Upgrade`/`Connection` - headers. - - Works around https://github.com/miguelgrinberg/python-engineio/issues/367 - where engineio mishandles such requests. - """ - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope['type'] != 'http': - await self.app(scope, receive, send) - return + return send_with_headers + async def _reject_invalid_websocket(self, scope: Scope, receive: Receive, send: Send) -> bool: path = scope.get('path', '') - if '/ws/socket.io' in path: - query_string = scope.get('query_string', b'').decode('latin-1', errors='replace') - query_params = parse_qs(query_string) - if query_params.get('transport', [''])[0] == 'websocket': - headers = _scope_headers(scope) - upgrade = headers.get('upgrade', '').lower() - connection_tokens = [token.strip() for token in headers.get('connection', '').lower().split(',')] - if upgrade != 'websocket' or 'upgrade' not in connection_tokens: - response = JSONResponse( - status_code=400, - content={'detail': 'Invalid WebSocket upgrade request'}, - ) - await response(scope, receive, send) - return + if '/ws/socket.io' not in path: + return False - await self.app(scope, receive, send) + query_params = parse_qs(scope.get('query_string', b'').decode('latin-1', errors='replace')) + if query_params.get('transport', [''])[0] != 'websocket': + return False + headers = _scope_headers(scope) + upgrade = headers.get('upgrade', '').lower() + connection_tokens = [token.strip() for token in headers.get('connection', '').lower().split(',')] + if upgrade == 'websocket' and 'upgrade' in connection_tokens: + return False -class RedirectMiddleware: - """Rewrites a couple of legacy entry-points to the SPA's own routes: + response = JSONResponse(status_code=400, content={'detail': 'Invalid WebSocket upgrade request'}) + await response(scope, receive, send) + return True - * ``GET /watch?v=ID`` (YouTube) → ``/?youtube=ID`` - * ``GET /?shared=…`` (PWA share-target) → ``/?youtube=…`` / - ``/?load-url=…`` / ``/?q=…`` - """ - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope['type'] != 'http' or scope.get('method', '').upper() != 'GET': - await self.app(scope, receive, send) - return + async def _redirect_legacy_url(self, scope: Scope, receive: Receive, send: Send) -> bool: + if scope.get('method', '').upper() != 'GET': + return False path = scope.get('path', '') raw_query = scope.get('query_string', b'') @@ -238,11 +171,9 @@ class RedirectMiddleware: # decode + parse_qs work for every other GET. (A false positive on the # substring check just falls through to the full parse below.) if not (path.endswith('/watch') or b'shared' in raw_query): - await self.app(scope, receive, send) - return + return False - query_string = raw_query.decode('latin-1', errors='replace') - query_params = parse_qs(query_string) + query_params = parse_qs(raw_query.decode('latin-1', errors='replace')) redirect_params: dict[str, str] = {} if path.endswith('/watch') and 'v' in query_params and query_params['v']: @@ -270,9 +201,41 @@ class RedirectMiddleware: redirect_url = f'/?{urlencode(redirect_params)}' response = RedirectResponse(url=redirect_url) await response(scope, receive, send) + return True + + return False + + def _rollback_session(self, message: str) -> None: + if not ScopedSession.registry.has(): return - await self.app(scope, receive, send) + try: + ScopedSession.rollback() + except Exception: + log.exception(message) + finally: + ScopedSession.remove() + + def _commit_session(self) -> None: + # Nothing in this request touched the sync session: committing would + # only instantiate one to run an empty transaction. + if not ScopedSession.registry.has(): + return + + try: + ScopedSession.commit() + except Exception: + log.exception('AppHTTPMiddleware: post-request commit failed; response was already sent to client') + try: + ScopedSession.rollback() + except Exception: + log.exception('AppHTTPMiddleware: rollback failed after commit failure') + raise + finally: + # CRITICAL: remove() returns the connection to the pool. + # Without this, connections remain "checked out" and + # accumulate as "idle in transaction" in PostgreSQL. + ScopedSession.remove() def _scope_headers(scope: Scope) -> dict[str, str]: diff --git a/backend/open_webui/utils/ask_user.py b/backend/open_webui/utils/ask_user.py new file mode 100644 index 0000000000..6c4168d2d7 --- /dev/null +++ b/backend/open_webui/utils/ask_user.py @@ -0,0 +1,144 @@ +from collections.abc import Callable + +from open_webui.utils.json_codec import JSONCodec + + +ASK_USER_NAME = 'ask_user' + + +def get_ask_user_tool_call(tool_calls: list[dict]) -> tuple[dict | None, str | None]: + ask_user_calls = [ + tool_call for tool_call in tool_calls if tool_call.get('function', {}).get('name') == ASK_USER_NAME + ] + if not ask_user_calls: + return None, None + if len(tool_calls) != 1: + return ask_user_calls[0], 'Error: ask_user must be called by itself after research.' + if len(ask_user_calls) != 1: + return ask_user_calls[0], 'Error: only one ask_user call is allowed per turn.' + return ask_user_calls[0], None + + +def normalize_ask_user_request(arguments: dict) -> dict: + questions = arguments.get('questions') + if not isinstance(questions, list) or not 1 <= len(questions) <= 3: + raise ValueError('ask_user requires 1-3 questions.') + + normalized_questions = [] + seen_ids = set() + allow_other = bool(arguments.get('allow_other', True)) + for index, question in enumerate(questions): + if not isinstance(question, dict): + raise ValueError('Each question must be an object.') + + question_id = str(question.get('id') or '').strip()[:64] + if not question_id: + raise ValueError('Each question requires a non-empty id.') + if question_id in seen_ids: + raise ValueError(f'Duplicate question id: {question_id}') + seen_ids.add(question_id) + + options = question.get('options') + if not isinstance(options, list) or not 2 <= len(options) <= 3: + raise ValueError('Each question requires 2-3 options.') + + normalized_options = [] + for option in options: + if not isinstance(option, dict): + raise ValueError('Each option must be an object.') + label = str(option.get('label') or '').strip()[:80] + description = str(option.get('description') or '').strip()[:240] + if not label or not description: + raise ValueError('Each option requires a label and description.') + normalized_options.append({'label': label, 'description': description}) + + question_text = str(question.get('question') or '').strip()[:500] + if not question_text: + raise ValueError('Each question requires question text.') + + normalized_questions.append( + { + 'id': question_id, + 'header': str(question.get('header') or '').strip()[:48] or f'Question {index + 1}', + 'question': question_text, + 'options': normalized_options, + 'allow_other': bool(question.get('allow_other', allow_other)), + } + ) + + timeout_ms = arguments.get('timeout_ms', 120_000) + if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or not 60_000 <= timeout_ms <= 240_000: + timeout_ms = 120_000 + + return { + 'questions': normalized_questions, + 'allow_other': allow_other, + 'timeout_ms': timeout_ms, + } + + +def stage_ask_user_tool_call( + tool_calls: list[dict], + output: list[dict], + make_output_id: Callable[[str], str], +) -> dict | None: + tool_call, error = get_ask_user_tool_call(tool_calls) + if not tool_call: + return None + + call_id = tool_call.get('id') or make_output_id('fc') + raw_arguments = tool_call.get('function', {}).get('arguments', '{}') + arguments = raw_arguments + + if not error: + try: + parsed_arguments = JSONCodec.loads(raw_arguments or '{}') + if not isinstance(parsed_arguments, dict): + raise ValueError('ask_user arguments must be an object.') + arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments)) + except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc: + error = f'Error: {exc}' + + item = { + 'type': 'function_call', + 'id': call_id or make_output_id('fc'), + 'call_id': call_id, + 'name': ASK_USER_NAME, + 'arguments': arguments, + 'status': 'completed' if error else 'pending', + } + + existing_item = next( + ( + existing + for existing in output + if existing.get('type') == 'function_call' + and ( + existing.get('call_id') == call_id + or existing.get('id') == tool_call.get('id') + or ( + not existing.get('call_id') + and existing.get('name') == ASK_USER_NAME + and existing.get('status') not in {'rejected', 'failed'} + ) + ) + ), + None, + ) + if existing_item: + existing_item.update(item) + else: + output.append(item) + + if error: + output.append( + { + 'type': 'function_call_output', + 'id': make_output_id('fco'), + 'call_id': call_id, + 'output': [{'type': 'input_text', 'text': error}], + 'status': 'completed', + } + ) + + return {'call_id': call_id, 'error': error, 'item': item} diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 890960bc58..7354b2c641 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -222,7 +222,7 @@ class AuditLoggingMiddleware: user = await get_current_user(request, None, None, get_http_authorization_cred(auth_header)) return user except Exception as e: - logger.debug(f'Failed to get authenticated user: {str(e)}') + logger.debug('Failed to get authenticated user: {}', e) return None diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 0384d6fcb9..408b518c13 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -4,7 +4,6 @@ import asyncio import base64 import hashlib import hmac -import json import logging import os import uuid @@ -40,6 +39,8 @@ from open_webui.models.auths import Auths from open_webui.models.config import Config from open_webui.models.users import Users from open_webui.utils.access_control import has_permission +from open_webui.utils.json_codec import JSONCodec +from open_webui.utils.misc import parse_duration from pytz import UTC log = logging.getLogger(__name__) @@ -86,11 +87,17 @@ def get_license_data(app, key): def data_handler(data): for k, v in data.items(): if k == 'resources': + # LICENSE covers these Open WebUI branding assets. + # Do not alter, remove, obscure, or replace them except as LICENSE permits: + # https://docs.openwebui.com/license. for p, c in v.items(): globals().get('override_static', lambda a, b: None)(p, c) elif k == 'count': setattr(app.state, 'USER_COUNT', v) elif k == 'name': + # LICENSE covers this Open WebUI product name. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. setattr(app.state, 'WEBUI_NAME', v) elif k == 'metadata': setattr(app.state, 'LICENSE_METADATA', v) @@ -137,13 +144,13 @@ def get_license_data(app, key): ln, lt = nt(lb) aesgcm = AESGCM(kb) - p = json.loads(aesgcm.decrypt(ln, lt, None)) + p = JSONCodec.loads(aesgcm.decrypt(ln, lt, None)) pk.verify(base64.b64decode(p['s']), p['p'].encode()) pb = base64.b64decode(p['p']) pn, pt = nt(pb) - data = json.loads(aesgcm.decrypt(pn, pt, None).decode()) + data = JSONCodec.loads(aesgcm.decrypt(pn, pt, None).decode()) exp = data.get('exp') if exp: @@ -245,8 +252,8 @@ async def is_valid_token(decoded, redis=None) -> bool: """ Check whether a JWT has been revoked. Two mechanisms: 1. Per-token (jti) — used by user-initiated sign-out (known jti). - 2. Per-user (revoked_at) — used by OIDC back-channel logout when - individual jti values are unknown; rejects tokens with iat <= revoked_at. + 2. Per-user (revoked_at) — used by password changes and OIDC back-channel + logout when individual jti values are unknown; rejects tokens with iat <= revoked_at. """ if redis: # Per-token revocation @@ -256,7 +263,7 @@ async def is_valid_token(decoded, redis=None) -> bool: if revoked: return False - # Per-user revocation (OIDC back-channel logout) + # Per-user revocation (password change, OIDC back-channel logout) user_id = decoded.get('id') if user_id: revoked_at = await redis.get(f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at') @@ -297,6 +304,27 @@ async def invalidate_token(request, token): ) +async def revoke_user_tokens(request, user_id: str): + """Reject every token already issued to a user. Requires Redis.""" + redis = request.app.state.redis + + if not redis: + log.warning( + 'Cannot revoke tokens for user %s: Redis is not configured, existing sessions stay valid until expiry.', + user_id, + ) + return + + # The marker has to outlive every token it revokes, so it never expires when tokens do not + expires_delta = parse_duration(await Config.get('auth.jwt_expiry')) + + await redis.set( + f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at', + str(int(datetime.now(UTC).timestamp())), + ex=int(expires_delta.total_seconds()) if expires_delta else None, + ) + + def extract_token_from_auth_header(auth_header: str): return auth_header[len('Bearer ') :] @@ -513,6 +541,39 @@ async def get_verified_user_by_token(token: str, redis=None): return user +async def get_verified_user_by_id(user_id: str | None): + if not user_id: + return None + + user = await Users.get_user_by_id(user_id) + if user is None or user.role not in VERIFIED_USER_ROLES: + return None + + return user + + +async def get_optional_verified_user_from_request(request: Request): + token = None + auth_token = get_http_authorization_cred(request.headers.get('Authorization')) + if auth_token: + token = auth_token.credentials + if token is None: + token = request.cookies.get('token') + if token is None and getattr(request.state, 'token', None): + token = request.state.token.credentials + if not token: + return None + + try: + if token.startswith('sk-'): + user = await get_current_user_by_api_key(request, token) + return user if user.role in VERIFIED_USER_ROLES else None + + return await get_verified_user_by_token(token, getattr(request.app.state, 'redis', None)) + except HTTPException: + return None + + def get_admin_user(user=Depends(get_current_user)): if user.role != 'admin': raise HTTPException( @@ -536,7 +597,7 @@ async def create_admin_user(email: str, password: str, name: str = 'Admin'): log.debug('Users already exist, skipping admin creation') return None - log.info(f'Creating admin account from environment variables: {email}') + log.info('Creating admin account from environment variables: %s', email) try: hashed = await get_password_hash(password) user = await Auths.insert_new_auth( @@ -546,7 +607,7 @@ async def create_admin_user(email: str, password: str, name: str = 'Admin'): role='admin', ) if user: - log.info(f'Admin account created successfully: {email}') + log.info('Admin account created successfully: %s', email) return user else: log.error('Failed to create admin account from environment variables') diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index b0415bc1d1..3ff9910e22 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -25,8 +25,7 @@ from typing import Optional from uuid import uuid4 from zoneinfo import ZoneInfo -from dateutil import parser as date_parser -from dateutil.rrule import rrulestr +from dateutil.rrule import HOURLY, MINUTELY, SECONDLY, rruleset, rrulestr from fastapi import Request from fastapi.security import HTTPAuthorizationCredentials from open_webui.constants import ERROR_MESSAGES @@ -36,6 +35,7 @@ from open_webui.models.automations import AutomationModel, AutomationRuns, Autom from open_webui.models.chats import ChatForm, Chats from open_webui.models.config import Config from open_webui.models.folders import Folders +from open_webui.models.messages import MessageForm from open_webui.models.users import Users from open_webui.utils.auth import create_token from open_webui.utils.misc import parse_duration @@ -77,44 +77,48 @@ def _parse_rule(s: str, now: Optional[datetime] = None): SECONDLY/MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00) so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10). """ - lines = s.splitlines() - rule_count = sum(1 for line in lines if line.upper().startswith('RRULE:')) - if 'EXRULE' in s.upper(): + upper = s.upper() + if 'EXRULE' in upper: raise ValueError('EXRULE is not supported in recurrence rules') - if rule_count > 1: + + parsed = rrulestr(s, ignoretz=True) + rules = parsed._rrule if isinstance(parsed, rruleset) else [parsed] + if len(rules) > 1: raise ValueError('only one RRULE is supported per recurrence rule') - rrule_line = next((line for line in lines if line.upper().startswith('RRULE:')), s) - raw = rrule_line.split(':', 1)[1] if rrule_line.upper().startswith('RRULE:') else rrule_line - parts = {k.upper(): v for k, v in (p.split('=', 1) for p in raw.split(';') if '=' in p)} - freq = parts.get('FREQ', '') + rule = rules[0] + start = rule._dtstart.replace(tzinfo=None) + anchor = now or datetime.now() + lines = s.splitlines() + stripped = '\n'.join(line for line in lines if not line.upper().startswith('DTSTART')) or s + has_dtstart = any(line.upper().startswith('DTSTART') for line in lines) + step = { + SECONDLY: timedelta(seconds=rule._interval), + MINUTELY: timedelta(minutes=rule._interval), + HOURLY: timedelta(hours=rule._interval), + }.get(rule._freq) - if freq in ('SECONDLY', 'MINUTELY', 'HOURLY'): - epoch = datetime(2000, 1, 1, 0, 0, 0) - anchor = now or datetime.now() - rule = '\n'.join(line for line in lines if not line.upper().startswith('DTSTART')) or s - dtstart = next((line.rsplit(':', 1)[-1] for line in lines if line.upper().startswith('DTSTART')), None) - interval = int(parts.get('INTERVAL', '1')) - if interval < 1: - raise ValueError('RRULE INTERVAL must be a positive integer') - if freq == 'SECONDLY': - step = timedelta(seconds=interval) - elif freq == 'MINUTELY': - step = timedelta(minutes=interval) - else: - step = timedelta(hours=interval) - if dtstart: - start = date_parser.parse(dtstart, ignoretz=True) - emitted = ((anchor - start) // step) if anchor > start else 0 - if 'BYMINUTE' in parts: - emitted *= len(parts['BYMINUTE'].split(',')) - if 'BYSECOND' in parts: - emitted *= len(parts['BYSECOND'].split(',')) - if emitted <= 100_000: - return rrulestr(s, ignoretz=True) - anchor = epoch + ((anchor - epoch) // step) * step - return rrulestr(rule, dtstart=anchor, ignoretz=True) - return rrulestr(s, ignoretz=True) + if step is None: + if not rule._dtstart.tzinfo: + return parsed + return rrulestr(stripped, dtstart=start, ignoretz=True) + + if rule._interval < 1: + raise ValueError('RRULE INTERVAL must be a positive integer') + dtstart = None + if has_dtstart: + emitted = ((anchor - start) // step) if anchor > start else 0 + emitted *= len(rule._byminute or (0,)) * len(rule._bysecond or (0,)) + if emitted <= 100_000: + if rule._dtstart.tzinfo: + dtstart = start + else: + return parsed + if not has_dtstart or dtstart is None: + epoch = datetime(2000, 1, 1) + dtstart = epoch + ((anchor - epoch) // step) * step + + return rrulestr(stripped, dtstart=dtstart, ignoretz=True) def validate_rrule(s: str, tz: str = None) -> None: @@ -124,6 +128,9 @@ def validate_rrule(s: str, tz: str = None) -> None: clock so that near-future schedules are not incorrectly rejected on servers whose system clock is ahead (e.g. UTC vs US timezones). """ + upper = s.upper() + if 'COUNT=' in upper and 'DTSTART' not in upper: + raise ValueError(ERROR_MESSAGES.AUTOMATION_COUNT_REQUIRES_DTSTART) zi = _resolve_tz(tz) now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now() try: @@ -211,8 +218,9 @@ async def scheduler_worker_loop(app) -> None: SCHEDULER_POLL_INTERVAL env var (default: 10 seconds). """ log.info( - f'Scheduler worker started (timer poll interval: {TIMER_POLL_INTERVAL}s, ' - f'scheduler poll interval: {SCHEDULER_POLL_INTERVAL}s)' + 'Scheduler worker started (timer poll interval: %ss, scheduler poll interval: %ss)', + TIMER_POLL_INTERVAL, + SCHEDULER_POLL_INTERVAL, ) next_scheduler_poll = 0.0 @@ -240,7 +248,7 @@ async def scheduler_worker_loop(app) -> None: async with get_async_db() as db: batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db) if batch: - log.info(f'Claimed {len(batch)} due automation(s)') + log.info('Claimed %s due automation(s)', len(batch)) for automation in batch: asyncio.create_task(execute_automation(app, automation)) except Exception: @@ -296,33 +304,17 @@ def _build_request( return request -def _resolve_model_tool_ids(app, model_id: str) -> list[str]: - """Read model-attached tool_ids from model config. - - The frontend does this in Chat.svelte (model.info.meta.toolIds). - The backend never auto-resolves them, so we must do it explicitly. - """ - models = getattr(app.state, 'MODELS', {}) - model = models.get(model_id, {}) - tool_ids = model.get('info', {}).get('meta', {}).get('toolIds', []) - return list(tool_ids) if tool_ids else [] - - -async def _resolve_model_features(app, model_id: str) -> dict: - """Read model default features from model config. - - The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds - + model.info.meta.capabilities). Enables features like web_search, - code_interpreter, image_generation when the model has them as defaults - AND the capability is enabled AND the admin has enabled the feature. - """ +async def _resolve_model_defaults(app, model_id: str) -> tuple[list[str], dict, list[str], Optional[str]]: models = getattr(app.state, 'MODELS', {}) model = models.get(model_id, {}) meta = model.get('info', {}).get('meta', {}) + tool_ids = list(meta.get('toolIds') or []) + filter_ids = list(meta.get('defaultFilterIds') or []) + terminal_id = meta.get('terminalId') or None default_feature_ids = meta.get('defaultFeatureIds', []) if not default_feature_ids: - return {} + return tool_ids, {}, filter_ids, terminal_id capabilities = meta.get('capabilities') or {} features = {} @@ -340,25 +332,7 @@ async def _resolve_model_features(app, model_id: str) -> dict: if capabilities.get(feature_id) and feature_checks[feature_id]: features[feature_id] = True - return features - - -def _resolve_model_filter_ids(app, model_id: str) -> list[str]: - """Read model default filter_ids from model config.""" - models = getattr(app.state, 'MODELS', {}) - model = models.get(model_id, {}) - filter_ids = model.get('info', {}).get('meta', {}).get('defaultFilterIds', []) - return list(filter_ids) if filter_ids else [] - - -def _resolve_model_terminal_id(app, model_id: str) -> Optional[str]: - """Read model default terminal_id from model config. - - The frontend does this in Chat.svelte (model.info.meta.terminalId). - """ - models = getattr(app.state, 'MODELS', {}) - model = models.get(model_id, {}) - return model.get('info', {}).get('meta', {}).get('terminalId') or None + return tool_ids, features, filter_ids, terminal_id async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None: @@ -409,10 +383,113 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) - log.warning(f'Failed to set terminal CWD: {e}') +async def _execute_channel_automation( + app, + automation: AutomationModel, + user, + prompt: str, + model_id: str, + token: str, +) -> None: + target = automation.data.get('target') or {} + channel_id = target.get('channel_id') + if not channel_id or not await Config.get('channels.enable'): + raise ValueError('Channel not found') + + model = getattr(app.state, 'MODELS', {}).get(model_id, {}) + request = _build_request(app, token=token) + + from open_webui.routers.channels import new_message_handler + + async with get_async_db() as db: + user_message, channel = await new_message_handler( + request, + channel_id, + MessageForm( + content=prompt, + data={}, + meta={'automation_id': automation.id}, + ), + user, + db, + ) + response_parent_id = ( + user_message.parent_id + if user_message.parent_id + else (user_message.id if await Config.get('channels.model_response_mode', 'thread') == 'thread' else None) + ) + assistant_message, channel = await new_message_handler( + request, + channel.id, + MessageForm( + parent_id=response_parent_id, + content='', + data={}, + meta={ + 'automation_id': automation.id, + 'model_id': model_id, + 'model_name': model.get('name', model_id), + }, + ), + user, + db, + ) + + tool_ids, features, filter_ids, _ = await _resolve_model_defaults(app, model_id) + + form_data = { + 'model': model_id, + 'messages': [ + { + 'role': 'system', + 'content': f'You are {model.get("name", model_id)}, participating in a channel conversation. Be concise and conversational.', + }, + {'role': 'user', 'content': f'{user.name if user else "User"}: {prompt}'}, + ], + 'stream': True, + 'chat_id': f'channel:{channel.id}', + 'id': assistant_message.id, + 'session_id': f'channel:{channel.id}', + 'automation_id': automation.id, + 'background_tasks': {}, + } + if tool_ids: + form_data['tool_ids'] = tool_ids + if features: + form_data['features'] = features + if filter_ids: + form_data['filter_ids'] = filter_ids + + await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + + from open_webui.socket.main import sio + + await sio.emit( + 'automation:result', + { + 'automation_id': automation.id, + 'name': automation.name, + 'chat_id': f'channel:{channel.id}', + 'message_id': assistant_message.id, + 'status': 'success', + }, + room=f'user:{automation.user_id}', + ) + + await _record_run(automation.id, 'success', chat_id=f'channel:{channel.id}') + await publish_event( + app, + EVENTS.AUTOMATION_RUN_COMPLETED, + actor=user, + subject_id=automation.id, + data={'name': automation.name, 'channel_id': channel.id, 'message_id': assistant_message.id}, + ) + + async def execute_automation(app, automation: AutomationModel) -> None: """Execute an automation through the full chat completion pipeline. - Creates a real chat, then calls chat_completion exactly like the frontend: + Creates a real chat or channel message, then calls chat_completion exactly like the frontend: session_id + chat_id + message_id → async task → pipeline handles everything (filters, model params, knowledge/RAG, tools, DB saves, webhooks). """ @@ -448,6 +525,20 @@ async def execute_automation(app, automation: AutomationModel) -> None: prompt = await prompt_template(automation.data['prompt'], user) model_id = automation.data['model_id'] + try: + expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h'))) + except ValueError: + expires_delta = None + token = create_token( + data={'id': user.id, 'typ': 'automation'}, + expires_delta=expires_delta or timedelta(hours=1), + ) + + target = automation.data.get('target') or {} + if target.get('type') == 'channel': + await _execute_channel_automation(app, automation, user, prompt, model_id, token) + return + folder_id = automation.folder_id if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id): await Automations.clear_folder_ids(automation.user_id, [folder_id]) @@ -524,12 +615,7 @@ async def execute_automation(app, automation: AutomationModel) -> None: ) # Resolve model defaults (frontend does this, backend doesn't) - tool_ids = _resolve_model_tool_ids(app, model_id) - features = await _resolve_model_features(app, model_id) - filter_ids = _resolve_model_filter_ids(app, model_id) - - # Resolve terminal from model config - terminal_id = _resolve_model_terminal_id(app, model_id) + tool_ids, features, filter_ids, terminal_id = await _resolve_model_defaults(app, model_id) # Build the same payload the frontend sends to /api/chat/completions form_data = { @@ -546,6 +632,7 @@ async def execute_automation(app, automation: AutomationModel) -> None: 'content': prompt, }, 'session_id': f'automation:{automation.id}', + 'automation_id': automation.id, 'background_tasks': {}, } if tool_ids: @@ -559,14 +646,6 @@ async def execute_automation(app, automation: AutomationModel) -> None: # Call the full chat completion pipeline (same as POST /api/chat/completions). # The handler reference is stored on app.state to avoid circular imports. - try: - expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h'))) - except ValueError: - expires_delta = None - token = create_token( - data={'id': user.id, 'typ': 'automation'}, - expires_delta=expires_delta or timedelta(hours=1), - ) request = _build_request(app, token=token) await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) @@ -667,7 +746,7 @@ async def _check_calendar_alerts(app) -> None: CalendarEventUpdateForm(meta={'alerted_at': now_ns}), ) except Exception: - log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True) + log.debug('Failed to mark event %s as alerted', event.id, exc_info=True) # Send target notification if user has one configured try: @@ -687,7 +766,7 @@ async def _check_calendar_alerts(app) -> None: message=event.title, ) except Exception: - log.debug(f'Failed to send notification for calendar alert {event.id}', exc_info=True) + log.debug('Failed to send notification for calendar alert %s', event.id, exc_info=True) async def _record_run( diff --git a/backend/open_webui/utils/calendar.py b/backend/open_webui/utils/calendar.py index e3feb972f0..16d0f2b659 100644 --- a/backend/open_webui/utils/calendar.py +++ b/backend/open_webui/utils/calendar.py @@ -4,12 +4,11 @@ Calendar utilities. RRULE expansion reusing the automation infra. """ +import datetime as dt import logging -from datetime import datetime, timedelta -from typing import Optional from zoneinfo import ZoneInfo -from open_webui.utils.automations import _parse_rule +from open_webui.utils.automations import _resolve_tz log = logging.getLogger(__name__) @@ -18,7 +17,7 @@ def expand_recurring_event( event_dict: dict, range_start_ns: int, range_end_ns: int, - tz: Optional[str] = None, + tz: str | None = None, max_instances: int = 5000, ) -> list[dict]: """Expand a recurring event into individual instances within a date range. @@ -32,16 +31,23 @@ def expand_recurring_event( if not rrule_str: return [event_dict] - range_start_dt = datetime.fromtimestamp(range_start_ns / 1_000_000_000) - range_end_dt = datetime.fromtimestamp(range_end_ns / 1_000_000_000) - scan_start = range_start_dt - timedelta(days=1) + user_timezone = _resolve_tz(tz) + + def to_local_datetime(timestamp_ns: int) -> dt.datetime: + if user_timezone: + return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None) + return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000) + + range_start = to_local_datetime(range_start_ns) + range_end = to_local_datetime(range_end_ns) + scan_start = range_start - dt.timedelta(days=1) original_start_ns = event_dict['start_at'] - original_start_dt = datetime.fromtimestamp(original_start_ns / 1_000_000_000) + original_start = to_local_datetime(original_start_ns) try: # Anchor to the event's real start so day-of-week / day-of-month are correct - rule = rrulestr(rrule_str, dtstart=original_start_dt, ignoretz=True) + rule = rrulestr(rrule_str, dtstart=original_start, ignoretz=True) except Exception: log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}') return [event_dict] @@ -50,17 +56,13 @@ def expand_recurring_event( duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None instances = [] - dt = rule.after(scan_start, inc=True) + occurrence_start = rule.after(scan_start, inc=True) - while dt and dt < range_end_dt and len(instances) < max_instances: - if tz: - try: - dt_tz = dt.replace(tzinfo=ZoneInfo(tz)) - instance_start_ns = int(dt_tz.timestamp() * 1_000_000_000) - except Exception: - instance_start_ns = int(dt.timestamp() * 1_000_000_000) + while occurrence_start and occurrence_start < range_end and len(instances) < max_instances: + if user_timezone: + instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000) else: - instance_start_ns = int(dt.timestamp() * 1_000_000_000) + instance_start_ns = int(occurrence_start.timestamp() * 1_000_000_000) if instance_start_ns >= range_start_ns: instance = { @@ -71,15 +73,15 @@ def expand_recurring_event( } instances.append(instance) - dt = rule.after(dt) + occurrence_start = rule.after(occurrence_start) return instances -def ns_from_date(year: int, month: int, day: int, tz: Optional[str] = None) -> int: +def ns_from_date(year: int, month: int, day: int, tz: str | None = None) -> int: """Create epoch nanoseconds from a date.""" if tz: - dt = datetime(year, month, day, tzinfo=ZoneInfo(tz)) + date_time = dt.datetime(year, month, day, tzinfo=ZoneInfo(tz)) else: - dt = datetime(year, month, day) - return int(dt.timestamp() * 1_000_000_000) + date_time = dt.datetime(year, month, day) + return int(date_time.timestamp() * 1_000_000_000) diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index fda6320749..d74c15e7d1 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -1,5 +1,4 @@ import asyncio -import json import logging import random import sys @@ -32,6 +31,7 @@ from open_webui.utils.filter import ( get_filter_functions, process_filter_functions, ) +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.models import check_model_access, get_all_models from open_webui.utils.payload import convert_payload_openai_to_ollama from open_webui.utils.response import ( @@ -68,7 +68,7 @@ async def generate_direct_chat_completion( ) channel = f'{user_id}:{session_id}:{request_id}' - logging.info(f'WebSocket channel: {channel}') + logging.info('WebSocket channel: %s', channel) if form_data.get('stream'): q = asyncio.Queue() @@ -95,7 +95,7 @@ async def generate_direct_chat_completion( } ) - log.info(f'res: {res}') + log.info('res: %s', res) if res.get('status', False): # Define a generator to stream responses @@ -108,14 +108,14 @@ async def generate_direct_chat_completion( if 'done' in data and data['done']: break # Stop streaming when 'done' is received - yield f'data: {json.dumps(data)}\n\n' + yield f'data: {JSONCodec.dumps(data)}\n\n' elif isinstance(data, str): if 'data:' in data: yield f'{data}\n\n' else: yield f'data: {data}\n\n' except Exception as e: - log.debug(f'Error in event generator: {e}') + log.debug('Error in event generator: %s', e) pass # Define a background task to run the event generator @@ -155,7 +155,7 @@ async def generate_chat_completion( bypass_filter: bool = False, bypass_system_prompt: bool = False, ): - log.debug(f'generate_chat_completion: {form_data}') + log.debug('generate_chat_completion: %s', form_data) if BYPASS_MODEL_ACCESS_CONTROL: bypass_filter = True @@ -184,7 +184,7 @@ async def generate_chat_completion( **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } - log.debug(f'direct connection to model: {request.state.model["id"]}') + log.debug('direct connection to model: %s', request.state.model['id']) else: models = request.app.state.MODELS @@ -249,7 +249,7 @@ async def generate_chat_completion( if form_data.get('stream') == True: async def stream_wrapper(stream): - yield f'data: {json.dumps({"selected_model_id": selected_model_id})}\n\n' + yield f'data: {JSONCodec.dumps({"selected_model_id": selected_model_id})}\n\n' async for chunk in stream: yield chunk @@ -316,7 +316,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any): if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: diff --git a/backend/open_webui/utils/chat_variables.py b/backend/open_webui/utils/chat_variables.py index 37438254aa..5ff2e21b05 100644 --- a/backend/open_webui/utils/chat_variables.py +++ b/backend/open_webui/utils/chat_variables.py @@ -1,9 +1,9 @@ from __future__ import annotations -import json import re from typing import Any +from open_webui.utils.json_codec import JSONCodec CHAT_VARIABLE_KEY_RE = re.compile(r'^[a-z][a-z0-9_]*$') CHAT_VARIABLE_ANY_RE = re.compile(r'{{\s*chat\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}') @@ -64,8 +64,8 @@ def parse_json_value(value: str) -> Any: if re.match(r'^[\[{]', value): try: - return json.loads(value) - except json.JSONDecodeError: + return JSONCodec.loads(value) + except JSONCodec.JSONDecodeError: return value return value @@ -185,7 +185,7 @@ def validate_user_variables(variables: Any) -> dict[str, str]: raise ChatVariablesError('User variables must be an object.') try: - if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: + if len(JSONCodec.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: raise ChatVariablesError('User variables are too large.') except TypeError: raise ChatVariablesError('User variables must be JSON serializable.') @@ -214,7 +214,7 @@ def validate_chat_variables( variables = normalize_chat_variables(variables) try: - if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: + if len(JSONCodec.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: raise ChatVariablesError('Chat variables are too large.') except TypeError: raise ChatVariablesError('Chat variables must be JSON serializable.') diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index cd6ce91ada..b290efd983 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -1,5 +1,4 @@ import asyncio -import json import logging import uuid from typing import Optional @@ -7,6 +6,7 @@ from typing import Optional import aiohttp import websockets from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS +from open_webui.utils.json_codec import JSONCodec from pydantic import BaseModel logger = logging.getLogger(__name__) @@ -127,7 +127,7 @@ class JupyterCodeExecuter: # send message msg_id = uuid.uuid4().hex await ws.send( - json.dumps( + JSONCodec.dumps( { 'header': { 'msg_id': msg_id, @@ -157,7 +157,7 @@ class JupyterCodeExecuter: try: # wait for message message = await asyncio.wait_for(ws.recv(), self.timeout) - message_data = json.loads(message) + message_data = JSONCodec.loads(message) # msg id not match, skip if message_data.get('parent_header', {}).get('msg_id') != msg_id: continue diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index e3c6f54e2a..aeb4e9875e 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging from typing import Any @@ -8,9 +7,10 @@ from fastapi.responses import JSONResponse from open_webui.models.chats import Chats from open_webui.models.config import Config from open_webui.utils.chat_id import is_saved_chat_id +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import get_content_from_message, get_last_user_message, get_message_list +from open_webui.utils.payload import apply_params_to_form_data from open_webui.utils.task import ( - get_task_model_id, prompt_template, prompt_variables_template, replace_messages_variable, @@ -234,6 +234,23 @@ def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: d return min(configured_threshold or global_threshold, global_cap) +def _usage_token_count(usage: dict) -> int: + prompt_tokens = int(usage.get('prompt_tokens') or usage.get('prompt_eval_count') or 0) + if not prompt_tokens and (usage.get('prompt_n') is not None or usage.get('cache_n') is not None): + prompt_tokens = int(usage.get('prompt_n') or 0) + int(usage.get('cache_n') or 0) + if not prompt_tokens: + prompt_tokens = int(usage.get('input_tokens') or 0) + + completion_tokens = int( + usage.get('completion_tokens') + or usage.get('output_tokens') + or usage.get('eval_count') + or usage.get('predicted_n') + or 0 + ) + return prompt_tokens + completion_tokens + + async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None: chat_data = chat.chat or {} history = chat_data.get('history') or {} @@ -262,25 +279,7 @@ async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict for idx in range(len(messages) - 1, -1, -1): usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage') - if isinstance(usage, dict) and ( - tokens := ( - int( - usage.get('prompt_tokens') - or usage.get('input_tokens') - or usage.get('prompt_eval_count') - or usage.get('prompt_n') - or 0 - ) - + int( - usage.get('completion_tokens') - or usage.get('output_tokens') - or usage.get('eval_count') - or usage.get('predicted_n') - or 0 - ) - + int(usage.get('cache_n') or 0) - ) - ): + if isinstance(usage, dict) and (tokens := _usage_token_count(usage)): tokens += _estimate_messages_tokens(messages[idx + 1 :]) return _build_context_usage(tokens, threshold) @@ -319,25 +318,7 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary: for idx in range(len(messages) - 1, -1, -1): usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage') - if isinstance(usage, dict) and ( - tokens := ( - int( - usage.get('prompt_tokens') - or usage.get('input_tokens') - or usage.get('prompt_eval_count') - or usage.get('prompt_n') - or 0 - ) - + int( - usage.get('completion_tokens') - or usage.get('output_tokens') - or usage.get('eval_count') - or usage.get('predicted_n') - or 0 - ) - + int(usage.get('cache_n') or 0) - ) - ): + if isinstance(usage, dict) and (tokens := _usage_token_count(usage)): return tokens + _estimate_messages_tokens(messages[idx + 1 :]) > threshold estimated = _estimate_tokens(system_prompt) + _estimate_tokens(summary or '') + _estimate_messages_tokens(messages) @@ -365,23 +346,11 @@ async def _generate_summary( from open_webui.utils.chat import generate_chat_completion task_config = await Config.get_many( - 'task.model.default', - 'task.model.external', + 'task.model.params', 'chat.context_compaction.model', ) context_compaction_model = task_config.get('chat.context_compaction.model') - task_model_id = ( - context_compaction_model - if context_compaction_model in models - else get_task_model_id( - model_id, - task_config.get('task.model.default'), - task_config.get('task.model.external'), - models, - ) - ) - if task_model_id not in models: - task_model_id = model_id + task_model_id = context_compaction_model if context_compaction_model in models else model_id if task_model_id not in models: raise ValueError('No available model for context compaction') @@ -394,22 +363,25 @@ async def _generate_summary( prompt = prompt_variables_template(prompt, {'{{PREVIOUS_SUMMARY}}': previous_summary or ''}) prompt = await prompt_template(prompt, user) - max_tokens = models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000) + task_model_params = task_config.get('task.model.params') or {} + if not isinstance(task_model_params, dict): + task_model_params = {} + task_model_params = {key: value for key, value in task_model_params.items() if value is not None and value != ''} + task_model_params = task_model_params or { + 'max_tokens': models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000) + } + payload = { 'model': task_model_id, 'messages': [{'role': 'user', 'content': prompt}], 'stream': False, - **( - {'max_tokens': max_tokens} - if models[task_model_id].get('owned_by') == 'ollama' - else {'max_completion_tokens': max_tokens} - ), 'metadata': { **(request.state.metadata if hasattr(request.state, 'metadata') else {}), 'task': 'context_compaction', }, } + payload = apply_params_to_form_data(payload, models[task_model_id], task_model_params) response = await generate_chat_completion(request, form_data=payload, user=user) summary = _response_text(response).strip() if summary: @@ -429,7 +401,7 @@ def _response_text(response: Any) -> str: if isinstance(response, JSONResponse): try: - response = json.loads(response.body.decode('utf-8', 'replace')) + response = JSONCodec.loads(response.body.decode('utf-8', 'replace')) except Exception: return '' @@ -477,7 +449,7 @@ def _estimate_tokens(value: Any) -> int: if not isinstance(value, str): try: - value = json.dumps(value, ensure_ascii=False) + value = JSONCodec.dumps(value, ensure_ascii=False) except Exception: value = str(value) diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 7d8b3e0a78..56be906288 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -172,22 +172,25 @@ async def process_filter_function( skip_files = ( function_module.file_handler if filter_type == 'inlet' and hasattr(function_module, 'file_handler') else None ) - valves_by_id = await apply_filter_valves(function_module, filter_context, valves_by_id, filter_ids, filter_id) try: + valves_by_id = await apply_filter_valves(function_module, filter_context, valves_by_id, filter_ids, filter_id) sig = inspect.signature(handler) params = get_filter_params(sig, filter_id, filter_type, form_data, extra_params) if '__user__' in sig.parameters: try: await apply_user_valves(function_module, filter_context, filter_id, params) - except Exception as e: - log.exception(f'Failed to get user values: {e}') + except Exception: + log.exception('Failed to get user valves for filter %s', filter_id) form_data = await run_filter_handler(handler, params) - except Exception as e: - log.debug(f'Error in {filter_type} handler {filter_id}: {e}') - raise e + except Exception: + if filter_type == 'inlet': + log.debug('Error in inlet filter %s', filter_id, exc_info=True) + else: + log.exception('Error in %s filter %s', filter_type, filter_id) + raise return form_data, valves_by_id, skip_files diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index 2f23879c8a..c8accbf676 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -20,6 +20,19 @@ log = logging.getLogger(__name__) USER_GROUPS_PLACEHOLDERS = ('{{USER_GROUPS}}', '{{USER_GROUP_IDS}}') +def normalize_bearer_token(token: Any) -> str: + return token.strip() if isinstance(token, str) else token or '' + + +def bearer_auth_header(token: Any) -> dict[str, str]: + token = normalize_bearer_token(token) + return {'Authorization': f'Bearer {token}'} if token else {} + + +def get_json_bearer_headers(token: Any = '') -> dict[str, str]: + return {'Content-Type': 'application/json', **bearer_auth_header(token)} + + def _mint_forward_user_jwt(user: Any) -> str: now = int(time.time()) payload = { diff --git a/backend/open_webui/utils/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py index bbf92a6545..11a0ebb8fe 100644 --- a/backend/open_webui/utils/images/comfyui.py +++ b/backend/open_webui/utils/images/comfyui.py @@ -1,4 +1,3 @@ -import json import logging import random import urllib.parse @@ -6,6 +5,7 @@ from typing import Optional import aiohttp from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.session_pool import get_session from pydantic import BaseModel @@ -17,7 +17,7 @@ default_headers = {'User-Agent': 'Mozilla/5.0'} async def queue_prompt(prompt, client_id, base_url, api_key): log.info('queue_prompt') p = {'prompt': prompt, 'client_id': client_id} - log.debug(f'queue_prompt data: {p}') + log.debug('queue_prompt data: %s', p) try: session = await get_session() async with session.post( @@ -76,7 +76,7 @@ async def _ws_get_images(ws, workflow, client_id, base_url, api_key): async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: - message = json.loads(msg.data) + message = JSONCodec.loads(msg.data) if message['type'] == 'executing': data = message['data'] if data['node'] is None and data['prompt_id'] == prompt_id: @@ -188,7 +188,7 @@ def _apply_workflow_nodes(workflow, nodes, model, payload): async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key): ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') - workflow = json.loads(payload.workflow.workflow) + workflow = JSONCodec.loads(payload.workflow.workflow) _apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload) headers = {'Authorization': f'Bearer {api_key}'} @@ -202,7 +202,7 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie ) as ws: log.info('WebSocket connection established.') log.info('Sending workflow to WebSocket server.') - log.debug(f'Workflow: {workflow}') + log.debug('Workflow: %s', workflow) images = await _ws_get_images(ws, workflow, client_id, base_url, api_key) except aiohttp.WSServerHandshakeError as e: log.exception(f'Failed to connect to WebSocket server: {e}') @@ -229,7 +229,7 @@ class ComfyUIEditImageForm(BaseModel): async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key): ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') - workflow = json.loads(payload.workflow.workflow) + workflow = JSONCodec.loads(payload.workflow.workflow) _apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload) headers = {'Authorization': f'Bearer {api_key}'} @@ -243,7 +243,7 @@ async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, client_i ) as ws: log.info('WebSocket connection established.') log.info('Sending workflow to WebSocket server.') - log.debug(f'Workflow: {workflow}') + log.debug('Workflow: %s', workflow) images = await _ws_get_images(ws, workflow, client_id, base_url, api_key) except aiohttp.WSServerHandshakeError as e: log.exception(f'Failed to connect to WebSocket server: {e}') diff --git a/backend/open_webui/utils/json_codec.py b/backend/open_webui/utils/json_codec.py index 0e5bdbd1b5..c3a42d95cb 100644 --- a/backend/open_webui/utils/json_codec.py +++ b/backend/open_webui/utils/json_codec.py @@ -3,7 +3,10 @@ Every module that would otherwise reach for stdlib ``json`` imports ``JSONCodec`` from here, so the whole app switches implementation from a single flag. With the flag off these are stdlib ``json`` and engineio's codec verbatim, so the default -behaviour is exactly what it was before orjson entered the picture. +behaviour is exactly what it was before orjson entered the picture. ``dumps_bytes`` +returns UTF-8 bytes for sinks that re-parse the payload; under orjson it skips +both the str round trip and the line-separator escaping ``dumps`` applies, so +never feed it to line-framed output such as SSE. """ from __future__ import annotations @@ -11,31 +14,51 @@ from __future__ import annotations import json as stdlib_json from engineio import json as engineio_json - from open_webui.env import ENABLE_ORJSON if ENABLE_ORJSON: import orjson + # Module-level because CPython rebuilds these dicts on every call. + FAST_PATH_KWARGS = ({'separators': (',', ':')}, {'ensure_ascii': False}) + class ORJSONCodec: """stdlib-``json``-compatible codec backed by orjson. - Anything orjson rejects (non-str dict keys, ints beyond 64 bits, ``NaN`` - literals) falls back to engineio's stdlib-based codec, which keeps its - oversized-integer guard for untrusted client payloads. + The fast path is not byte-for-byte stdlib: it is always compact, formats + floats orjson's way (``1e16``, not ``1e+16``), and is raw UTF-8 apart from + the three line separators ``dumps`` escapes, so a ``separators`` caller loses + stdlib's ASCII escaping and an ``ensure_ascii=False`` caller loses its + spacing. ``dumps`` also serializes ``datetime``/``UUID``/dataclasses that + stdlib refuses, and encodes ``NaN``/``Infinity`` as ``null``. ``loads`` + decodes integers above ``2**64-1`` or below ``-2**63`` as ``float`` and does + not enforce engineio's 100-digit integer-literal limit. + + What orjson does reject (non-str dict keys and oversized ints on ``dumps``, + the ``NaN``/``Infinity`` literals on ``loads``) falls back to engineio's + stdlib-based codec, and with it stdlib's formatting. """ JSONDecodeError = engineio_json.JSONDecodeError @staticmethod def dumps(obj, *args, **kwargs): + if args or (kwargs and kwargs not in FAST_PATH_KWARGS): + return engineio_json.dumps(obj, *args, **kwargs) try: - return orjson.dumps(obj).decode('utf-8') + serialized = orjson.dumps(obj).decode('utf-8') except (TypeError, ValueError): return engineio_json.dumps(obj, *args, **kwargs) + # Raw, these three split an SSE frame reassembled with ``splitlines()``. + # A dict-table translate walks char by char; chained replace runs on C fast paths. + if '\u2028' in serialized or '\u2029' in serialized or '\x85' in serialized: + return serialized.replace('\u2028', '\\u2028').replace('\u2029', '\\u2029').replace('\x85', '\\u0085') + return serialized @staticmethod def loads(s, *args, **kwargs): + if args or kwargs: + return engineio_json.loads(s, *args, **kwargs) try: return orjson.loads(s) except (TypeError, ValueError): @@ -45,6 +68,17 @@ if ENABLE_ORJSON: JSONCodec = ORJSONCodec # Codec handed to the socket.io/engineio managers, which default to their own. SOCKETIO_JSON = ORJSONCodec + + def dumps_bytes(obj) -> bytes: + """JSON as UTF-8 bytes, skipping the str round trip and the escaping ``dumps`` does.""" + try: + return orjson.dumps(obj) + except (TypeError, ValueError): + return engineio_json.dumps(obj).encode('utf-8') else: JSONCodec = stdlib_json SOCKETIO_JSON = engineio_json + + def dumps_bytes(obj) -> bytes: + """JSON as UTF-8 bytes; here simply ``dumps`` encoded.""" + return stdlib_json.dumps(obj).encode('utf-8') diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index fb0d702ed4..f54b7f9840 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -19,6 +19,7 @@ from open_webui.env import ( LOG_FORMAT, LOGURU_DIAGNOSE, ) +from open_webui.utils.json_codec import JSONCodec if TYPE_CHECKING: from loguru import Message, Record @@ -34,7 +35,7 @@ def stdout_format(record: 'Record') -> str: str: A formatted log string intended for stdout. """ if record['extra']: - record['extra']['extra_json'] = json.dumps(record['extra']) + record['extra']['extra_json'] = JSONCodec.dumps(record['extra']) extra_format = ' - {extra[extra_json]}' else: extra_format = '' @@ -111,10 +112,14 @@ class InterceptHandler(logging.Handler): frame = frame.f_back depth += 1 - logger.opt(depth=depth, exception=record.exc_info).bind(**self._get_extras()).log(level, record.getMessage()) + message = record.getMessage() + logger.opt(depth=depth, exception=record.exc_info).bind(**self._get_extras()).log(level, message) if ENABLE_OTEL and ENABLE_OTEL_LOGS: from open_webui.utils.telemetry.logs import otel_handler + # reuse the message we built so %-args format once; a non-str msg is left alone, otel exports it structured + if isinstance(record.msg, str): + record.msg, record.args = message, None otel_handler.emit(record) def _get_extras(self): diff --git a/backend/open_webui/utils/memory.py b/backend/open_webui/utils/memory.py index ca84517c1d..96eb28a6e1 100644 --- a/backend/open_webui/utils/memory.py +++ b/backend/open_webui/utils/memory.py @@ -1,15 +1,14 @@ from __future__ import annotations import asyncio -import json import logging import re from typing import Any from fastapi import HTTPException - from open_webui.models.config import Config from open_webui.models.memories import Memories +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import add_or_update_system_message, get_content_from_message log = logging.getLogger(__name__) @@ -123,7 +122,7 @@ def search_memory_rows( def sort_key(memory): rank = _path_rank(memory.path, lookup_path) if lookup_path else None - return rank if rank is not None else (9, 0), -(memory.updated_at or 0) + return rank if rank is not None else (9, 0), -(memory.updated_at or 0), memory.id or '' return sorted(rows, key=sort_key)[: max(1, min(limit or 20, 100))] @@ -214,10 +213,10 @@ def read_memory_path_rows( def sort_key(memory): if memory.path == lookup_path: - return (0, 0, -(memory.updated_at or 0)) + return (0, 0, -(memory.updated_at or 0), memory.id or '') if memory.path and memory.path.startswith(f'{lookup_path}/'): - return (1, len(_path_parts(memory.path)), -(memory.updated_at or 0)) - return (2, -len(_path_parts(memory.path)), -(memory.updated_at or 0)) + return (1, len(_path_parts(memory.path)), -(memory.updated_at or 0), memory.id or '') + return (2, -len(_path_parts(memory.path)), -(memory.updated_at or 0), memory.id or '') return { 'path': lookup_path, @@ -233,7 +232,7 @@ def memory_path_hints(query: str, memories: list, limit: int = 6) -> list[str]: return [] hints: list[str] = [] - for memory in memories or []: + for memory in sorted(memories or [], key=lambda item: (item.path or '', item.content or '', item.id or '')): path = memory.path if not path or path in hints: continue @@ -320,7 +319,7 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None seen_ids = set() for memory in sorted( [memory for memory in (all_memories or []) if memory.type == 'user'], - key=lambda item: (item.path or '', item.updated_at), + key=lambda item: (item.path or '', item.updated_at or 0, item.id or ''), ): seen_ids.add(memory.id) sections['user'].append(memory_label(memory)) @@ -361,12 +360,14 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None sections[Memories.normalize_memory_type(metadata.get('type'))].append(label) parts = [] - if sections['user']: - parts.append('[User Memory]\n' + '\n'.join(f'- {memory}' for memory in sections['user'])) - if sections['neighborhood']: - parts.append('[Memory Neighborhood]\n' + '\n'.join(f'- {memory}' for memory in sections['neighborhood'])) - if sections['context']: - parts.append('[Relevant Context]\n' + '\n'.join(f'- {memory}' for memory in sections['context'])) + for title, key in ( + ('User Memory', 'user'), + ('Memory Neighborhood', 'neighborhood'), + ('Relevant Context', 'context'), + ): + if sections[key]: + ordered = sorted(sections[key], key=lambda memory: (memory.casefold(), memory)) + parts.append(f'[{title}]\n' + '\n'.join(f'- {memory}' for memory in ordered)) if not parts: return form_data @@ -458,7 +459,7 @@ async def review_memory_after_turn( try: done_task.result() except Exception as e: - log.debug(f'Memory review failed: {e}') + log.debug('Memory review failed: %s', e) task.add_done_callback(log_failure) @@ -565,6 +566,9 @@ Conversation: 'messages': [ { 'role': 'system', + # LICENSE covers this Open WebUI system identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'content': "You are Open WebUI's private memory reviewer. Return only valid JSON.", }, {'role': 'user', 'content': review_prompt}, @@ -590,7 +594,7 @@ Conversation: return [] try: - parsed = json.loads(content[start : end + 1]) + parsed = JSONCodec.loads(content[start : end + 1]) except Exception: return [] diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 271d072992..cc5f57e0c3 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -5,6 +5,7 @@ import copy import inspect import json import logging +import mimetypes import os import random import re @@ -40,6 +41,8 @@ from open_webui.env import ( GLOBAL_LOG_LEVEL, RAG_SYSTEM_CONTEXT, ) +from open_webui.events import EVENTS, publish_event +from open_webui.models.access_grants import AccessGrants from open_webui.models.chats import Chats from open_webui.models.config import Config from open_webui.models.folders import Folders @@ -47,7 +50,6 @@ from open_webui.models.models import Models from open_webui.models.notes import Notes from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import UserModel, Users -from open_webui.events import EVENTS, publish_event from open_webui.retrieval.utils import get_sources_from_items from open_webui.routers.images import ( CreateImageForm, @@ -75,10 +77,11 @@ from open_webui.socket.main import ( get_event_call, get_event_emitter, ) +from open_webui.tasks import clear_response_stream, save_response_stream from open_webui.utils.access_control import has_connection_access, has_permission -from open_webui.models.access_grants import AccessGrants from open_webui.utils.access_control.files import get_owner_accessible_folder_files from open_webui.utils.access_control.folders import has_folder_access +from open_webui.utils.ask_user import stage_ask_user_tool_call from open_webui.utils.chat import generate_chat_completion from open_webui.utils.chat_id import is_saved_chat_id from open_webui.utils.code_interpreter import execute_code_jupyter @@ -94,16 +97,13 @@ from open_webui.utils.filter import ( get_filter_functions, process_filter_functions, ) - from open_webui.utils.json_codec import JSONCodec from open_webui.utils.mcp.client import MCPClient from open_webui.utils.memory import add_memory_context, review_memory_after_turn from open_webui.utils.misc import ( add_or_update_system_message, add_or_update_user_message, - convert_logit_bias_input_to_json, convert_output_to_messages, - deep_update, extract_urls, get_content_from_message, get_last_assistant_message, @@ -111,6 +111,8 @@ from open_webui.utils.misc import ( get_last_user_message_item, get_message_list, get_output_text, + get_response_error_detail, + get_reasoning_details, get_system_message, is_string_allowed, merge_system_messages, @@ -119,7 +121,7 @@ from open_webui.utils.misc import ( set_last_user_message_content, strip_empty_content_blocks, ) -from open_webui.utils.payload import apply_system_prompt_to_body, resolve_system_prompt +from open_webui.utils.payload import apply_params_to_form_data, apply_system_prompt_to_body, resolve_system_prompt from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.response import merge_usage, normalize_usage from open_webui.utils.sanitize import sanitize_code @@ -142,6 +144,48 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +def _is_tool_result_error(value: Any) -> bool: + if isinstance(value, str): + text = value.strip().lower() + if ( + text.startswith('error:') + or text.startswith('exception:') + or text.startswith('traceback') + or text.startswith('http error!') + ): + return True + + parsed = value + while isinstance(parsed, str): + try: + parsed = JSONCodec.loads(parsed) + except (JSONCodec.JSONDecodeError, TypeError, ValueError): + break + + if not isinstance(parsed, dict): + return False + + error = parsed.get('error') + if isinstance(error, str): + has_error = bool(error.strip()) + else: + has_error = isinstance(error, (dict, list)) and bool(error) + if has_error: + return True + + status = parsed.get('status') + if isinstance(status, str) and status.strip().lower() in {'error', 'failed'}: + return True + + if parsed.get('success') is False or parsed.get('ok') is False: + message = parsed.get('message') + return has_error or ( + bool(message.strip()) if isinstance(message, str) else isinstance(message, (dict, list)) and bool(message) + ) + + return False + + async def publish_chat_finished_event( request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None ): @@ -204,6 +248,64 @@ def output_id(prefix: str) -> str: return f'{prefix}_{uuid4().hex[:24]}' +def build_terminal_file_tool_result( + tool_function_name: str, + tool_function_params: dict, + tool_result: Any, + tool: dict | None, + metadata: dict | None, +) -> dict | None: + if isinstance(tool_result, (list, tuple)) and tool_result and isinstance(tool_result[0], dict): + tool_result = tool_result[0] + + if ( + tool_function_name != 'display_file' + or tool_function_params.get('inline') is not True + or not isinstance(tool_result, dict) + or tool_result.get('exists') is False + ): + return None + + tool_id = (tool or {}).get('tool_id', '') + terminal_id = metadata.get('terminal_id') if metadata else None + if isinstance(tool_id, str) and tool_id.startswith('terminal:'): + terminal_id = tool_id.split(':', 1)[1] + + server_url = ((tool or {}).get('server') or {}).get('url') + terminal_selector = terminal_id or server_url + path = tool_result.get('path') or tool_function_params.get('path') + if not terminal_selector or not path: + return None + mime_type, _ = mimetypes.guess_type(path) + mime_type = mime_type or 'application/octet-stream' + page = tool_result.get('page') or tool_function_params.get('page') + + return { + **tool_result, + 'type': 'file', + 'source': 'open_terminal', + 'displayed': True, + 'terminal_selector': terminal_selector, + **({'terminal_id': terminal_id} if terminal_id else {}), + **({'terminal_url': server_url} if server_url and not terminal_id else {}), + 'session_id': metadata.get('chat_id') if metadata else None, + 'path': path, + 'full_path': tool_result.get('full_path') or path, + 'name': tool_result.get('name') or os.path.basename(path), + 'mime_type': tool_result.get('mime_type') or tool_result.get('content_type') or mime_type, + 'content_type': tool_result.get('content_type') or tool_result.get('mime_type') or mime_type, + **({'page': page} if page else {}), + } + + +def tool_result_content(tool_result: Any) -> str: + if not tool_result: + return '' + if isinstance(tool_result, (dict, list)): + return JSONCodec.dumps(tool_result, ensure_ascii=False) + return str(tool_result) + + def merge_streamed_reasoning_details(target: list, details) -> None: items = details if isinstance(details, list) else [details] for item in items: @@ -242,7 +344,7 @@ def _split_tool_calls( def split_json_objects(raw: str) -> list[str]: if not isinstance(raw, str): - raw = '' if raw is None else json.dumps(raw) + raw = '' if raw is None else JSONCodec.dumps(raw) decoder = json.JSONDecoder() results = [] @@ -257,7 +359,7 @@ def _split_tool_calls( _, end = decoder.raw_decode(raw, position) results.append(raw[position:end].strip()) position = end - except json.JSONDecodeError: + except JSONCodec.JSONDecodeError: return [raw] return results or [raw] @@ -267,7 +369,7 @@ def _split_tool_calls( function = tool_call.setdefault('function', {}) arguments = function.get('arguments') if not isinstance(arguments, str): - arguments = '' if arguments is None else json.dumps(arguments) + arguments = '' if arguments is None else JSONCodec.dumps(arguments) function['arguments'] = arguments split_arguments = split_json_objects(arguments) @@ -302,7 +404,7 @@ def get_citation_source_from_tool_result( try: try: tool_result = JSONCodec.loads(tool_result) - except (json.JSONDecodeError, TypeError): + except (JSONCodec.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 [] @@ -475,6 +577,23 @@ def deep_merge(target, source): return source +RESPONSE_COMPLETION_RESPONSE_FIELDS = ('error', 'id', 'output', 'usage') + + +def get_response_completion_event_data(event: dict) -> dict: + """Build the data payload for response:completion events.""" + response = event.get('response') + if not isinstance(response, dict): + return event + + response_data = {key: response[key] for key in RESPONSE_COMPLETION_RESPONSE_FIELDS if key in response} + + return { + **event, + 'response': response_data, + } + + def handle_responses_streaming_event( data: dict, current_output: list, @@ -501,7 +620,22 @@ def handle_responses_streaming_event( item = data.get('item', {}) if item: new_output = list(current_output) - new_output.append(item) + output_index = data.get('output_index', len(new_output)) + existing_index = next( + ( + idx + for idx, existing in enumerate(new_output) + if (item.get('id') and existing.get('id') == item.get('id')) + or (item.get('call_id') and existing.get('call_id') == item.get('call_id')) + ), + None, + ) + if existing_index is not None: + new_output[existing_index] = item + elif 0 <= output_index < len(new_output): + new_output.insert(output_index, item) + else: + new_output.append(item) return new_output, None return current_output, None @@ -664,7 +798,21 @@ def handle_responses_streaming_event( current_val = {} if isinstance(delta, dict) else '' item[key] = deep_merge(current_val, delta) - return new_output, None + return new_output, None + + return current_output, None + + elif event_type == 'response.output_item.done': + # Delta Event: Output item complete + item = data.get('item') + output_index = data.get('output_index', len(current_output) - 1) + + new_output = list(current_output) + if item and 0 <= output_index < len(current_output): + new_output[output_index] = item + elif item: + new_output.append(item) + return new_output, {} elif event_type.startswith('response.') and event_type.endswith('.done'): # Delta Events: response.content_part.done, response.text.done, etc. @@ -710,12 +858,8 @@ def handle_responses_streaming_event( return new_output, {} return current_output, None - # 2. Skip Output Item done (handled specifically below) - if type_name == 'output_item': - pass - - # 3. Generic Field Done (text.done, audio.done) - elif type_name not in ['completed', 'failed']: + # 2. Generic Field Done (text.done, audio.done) + if type_name not in ['completed', 'failed']: output_index = data.get('output_index', len(current_output) - 1) if current_output and 0 <= output_index < len(current_output): key = ( @@ -759,24 +903,13 @@ def handle_responses_streaming_event( return current_output, None - elif event_type == 'response.output_item.done': - # Delta Event: Output item complete - item = data.get('item') - output_index = data.get('output_index', len(current_output) - 1) - - new_output = list(current_output) - if item and 0 <= output_index < len(current_output): - new_output[output_index] = item - elif item: - new_output.append(item) - return new_output, {} - elif event_type == 'response.completed': # State Machine Event: Completed response_data = data.get('response', {}) final_output = response_data.get('output') - new_output = final_output if final_output is not None else current_output + # Some providers send an empty output on response.completed despite having streamed items + new_output = final_output if final_output else current_output # Ensure reasoning items are marked as completed in the final output if new_output: @@ -1003,7 +1136,7 @@ async def process_tool_result( if isinstance(text, str): try: text = JSONCodec.loads(text) - except json.JSONDecodeError: + except JSONCodec.JSONDecodeError: pass tool_response.append(text) elif item.get('type') in ['image', 'audio']: @@ -1031,7 +1164,7 @@ async def process_tool_result( if isinstance(text, str) and text: try: text = JSONCodec.loads(text) - except json.JSONDecodeError: + except JSONCodec.JSONDecodeError: pass tool_response.append(text) elif resource.get('blob'): @@ -1098,6 +1231,8 @@ async def terminal_event_handler( return if tool_function_name == 'display_file': + if tool_function_params.get('inline') is True: + return path = tool_function_params.get('path', '') if not path: return @@ -1106,15 +1241,19 @@ async def terminal_event_handler( if isinstance(parsed, str): try: parsed = JSONCodec.loads(parsed) - except (json.JSONDecodeError, TypeError): + except (JSONCodec.JSONDecodeError, TypeError): pass if isinstance(parsed, dict) and parsed.get('exists') is False: return + page = tool_function_params.get('page') await event_emitter( { 'type': f'terminal:{tool_function_name}', - 'data': {'path': path}, + 'data': { + 'path': path, + **({'page': page} if page else {}), + }, } ) elif tool_function_name in ('write_file', 'replace_file_content'): @@ -1198,7 +1337,7 @@ async def chat_completion_tools_handler( sources = [] specs = [tool['spec'] for tool in tools.values()] - tools_specs = json.dumps(specs, ensure_ascii=False) + tools_specs = JSONCodec.dumps(specs, ensure_ascii=False) tools_prompt_template = task_config.get('task.tools.prompt_template') if tools_prompt_template != '': @@ -1211,9 +1350,9 @@ async def chat_completion_tools_handler( try: response = await generate_chat_completion(request, form_data=payload, user=user) - log.debug(f'{response=}') + log.debug('response=%r', response) content = await get_content_from_response(response) - log.debug(f'{content=}') + log.debug('content=%r', content) if not content: return body, {} @@ -1228,7 +1367,7 @@ async def chat_completion_tools_handler( async def tool_call_handler(tool_call): nonlocal skip_files - log.debug(f'{tool_call=}') + log.debug('tool_call=%r', tool_call) tool_function_name = tool_call.get('name', None) if tool_function_name not in tools: @@ -1268,7 +1407,7 @@ async def chat_completion_tools_handler( tool_result = await tool_function(**tool_function_params) except Exception as e: - tool_result = str(e) + tool_result = {'error': str(e)} tool_result, tool_result_files, tool_result_embeds = await process_tool_result( request, @@ -1342,13 +1481,13 @@ async def chat_completion_tools_handler( await tool_call_handler(result) except Exception as e: - log.debug(f'Error: {e}') + log.debug('Error: %s', e) content = None except Exception as e: - log.debug(f'Error: {e}') + log.debug('Error: %s', e) content = None - log.debug(f'tool_contexts: {sources}') + log.debug('tool_contexts: %s', sources) if skip_files and 'files' in body.get('metadata', {}): del body['metadata']['files'] @@ -1582,11 +1721,10 @@ async def add_file_context(messages: list, chat_id: str, user) -> list: stored_messages = get_message_list(history.get('messages', {}), history.get('currentId')) def format_file_tag(file): - file_id = file.get('id') or file.get('url') - attrs = f'type="{file.get("type", "file")}"' - if file_id: - attrs += f' id="{file_id}"' - attrs += f' url="{file["url"]}"' + # Every file reaching here has a url or a chat id, so id is always set. + attrs = f'type="{file.get("type", "file")}" id="{file.get("id") or file.get("url")}"' + if file.get('url'): + attrs += f' url="{file["url"]}"' if file.get('content_type'): attrs += f' content_type="{file["content_type"]}"' if file.get('name'): @@ -1603,15 +1741,17 @@ async def add_file_context(messages: list, chat_id: str, user) -> list: stored_user_messages = [m for m in stored_messages if m.get('role') == 'user'] for message, stored_message in zip(user_messages, stored_user_messages): - files_with_urls = [ + # Chat references carry no url - they are addressed by id via view_chat. + attached_files = [ file for file in stored_message.get('files', []) - if file.get('url') and not file.get('url').startswith('data:') + if (file.get('url') and not file.get('url').startswith('data:')) + or (file.get('type') == 'chat' and file.get('id')) ] - if not files_with_urls: + if not attached_files: continue - file_tags = [format_file_tag(file) for file in files_with_urls] + file_tags = [format_file_tag(file) for file in attached_files] file_context = '\n' + '\n'.join(file_tags) + '\n\n\n' content = message.get('content', '') @@ -1635,12 +1775,6 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra message_list = form_data.get('messages', []) else: chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) - await __event_emitter__( - { - 'type': 'status', - 'data': {'description': 'Creating image', 'done': False}, - } - ) messages_map = chat.chat.get('history', {}).get('messages', {}) message_id = chat.chat.get('history', {}).get('currentId') @@ -1660,9 +1794,22 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra for image in images: input_images.append(image) + # Called directly, bypassing the /images routes that enforce these switches. + editing = len(input_images) > 0 and await Config.get('images.edit.enable') + if not editing and not await Config.get('image_generation.enable'): + return form_data + + if is_saved_chat_id(chat_id): + await __event_emitter__( + { + 'type': 'status', + 'data': {'description': 'Creating image', 'done': False}, + } + ) + system_message_content = '' - if len(input_images) > 0 and await Config.get('images.edit.enable'): + if editing: # Edit image(s) try: images = await image_edits( @@ -1720,6 +1867,19 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra system_message_content = f'Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}' + elif not await Config.get('image_generation.enable'): + await __event_emitter__( + { + 'type': 'status', + 'data': { + 'description': 'Image generation is disabled', + 'done': True, + }, + } + ) + + system_message_content = 'Image generation was requested but the feature is currently disabled by the administrator, so no image was created. Let the user know that image generation is currently unavailable.' + else: # Create image(s) if await Config.get('image_generation.prompt.enable'): @@ -1830,7 +1990,8 @@ async def chat_completion_files_handler( __event_emitter__ = extra_params['__event_emitter__'] sources = [] - if files := body.get('metadata', {}).get('files', None): + files = [item for item in (body.get('metadata', {}).get('files', None) or []) if item.get('type') != 'filesystem'] + if files: # Check if all files are in full context mode all_full_context = all(item.get('context') == 'full' for item in files) @@ -1913,7 +2074,7 @@ async def chat_completion_files_handler( except Exception as e: log.exception(e) - log.debug(f'rag_contexts:sources: {sources}') + log.debug('rag_contexts:sources: %s', sources) unique_ids = set() for source in sources or []: @@ -1944,59 +2105,6 @@ async def chat_completion_files_handler( return body, {'sources': sources} -def apply_params_to_form_data(form_data, model): - params = form_data.pop('params', {}) - custom_params = params.pop('custom_params', {}) - - open_webui_params = { - 'stream_response': bool, - 'stream_delta_chunk_size': int, - 'function_calling': str, - 'reasoning_tags': list, - 'compact_token_threshold': int, - 'system': str, - 'note_id': str, - } - - for key in list(params.keys()): - if key in open_webui_params: - del params[key] - - if custom_params: - # Attempt to parse custom_params if they are strings - for key, value in custom_params.items(): - if isinstance(value, str): - try: - # Attempt to parse the string as JSON - custom_params[key] = JSONCodec.loads(value) - except json.JSONDecodeError: - # If it fails, keep the original string - pass - - # If custom_params are provided, merge them into params - params = deep_update(params, custom_params) - - if model.get('owned_by') == 'ollama': - # Ollama specific parameters - form_data['options'] = params - else: - if isinstance(params, dict): - for key, value in params.items(): - if value is not None: - form_data[key] = value - - if 'logit_bias' in params and params['logit_bias'] is not None: - try: - logit_bias = convert_logit_bias_input_to_json(params['logit_bias']) - - if logit_bias: - form_data['logit_bias'] = JSONCodec.loads(logit_bias) - except Exception as e: - log.exception(f'Error parsing logit_bias: {e}') - - return form_data - - async def convert_url_images_to_base64(form_data, user=None): messages = form_data.get('messages', []) @@ -2012,7 +2120,13 @@ async def convert_url_images_to_base64(form_data, user=None): new_content.append(item) continue - image_url = item.get('image_url', {}).get('url', '') + image_url_data = item.get('image_url', {}) + if isinstance(image_url_data, dict): + image_url = image_url_data.get('url') or '' + elif isinstance(image_url_data, str): + image_url = image_url_data + else: + image_url = '' if image_url.startswith('data:image/'): new_content.append(item) continue @@ -2020,16 +2134,19 @@ async def convert_url_images_to_base64(form_data, user=None): try: base64_data = await get_image_base64_from_url(image_url, user=user) if base64_data: + image_url_payload = {'url': base64_data} + if isinstance(image_url_data, dict) and image_url_data.get('detail'): + image_url_payload['detail'] = image_url_data['detail'] new_content.append( { 'type': 'image_url', - 'image_url': {'url': base64_data}, + 'image_url': image_url_payload, } ) else: new_content.append(item) except Exception as e: - log.debug(f'Error converting image URL to base64: {e}') + log.debug('Error converting image URL to base64: %s', e) new_content.append(item) message['content'] = new_content @@ -2037,10 +2154,13 @@ async def convert_url_images_to_base64(form_data, user=None): return form_data +MESSAGE_REPLAY_KEYS = ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage', 'model') + + async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[dict]]: """ Load the message chain from DB up to message_id, - keeping only LLM-relevant fields (role, content, output). + keeping only fields needed to rebuild the LLM payload. """ messages_map = await Chats.get_messages_map_by_chat_id(chat_id) if not messages_map: @@ -2050,10 +2170,7 @@ async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[ if not db_messages: return None - return [ - {k: v for k, v in msg.items() if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage')} - for msg in db_messages - ] + return [{k: v for k, v in msg.items() if k in MESSAGE_REPLAY_KEYS} for msg in db_messages] def get_reasoning_format(model: dict) -> str | None: @@ -2061,18 +2178,26 @@ def get_reasoning_format(model: dict) -> str | None: Determine how reasoning should be included in reconstructed messages. Returns: - 'think_tags': Ollama expects tags in content. + 'thinking': Ollama expects reasoning in the native thinking field. + 'think_tags': wrap reasoning in tags inside content. 'reasoning_content': llama.cpp supports reasoning_content as a top-level field. None: skip reasoning (safe default for strict providers). """ provider = model.get('provider', '') - if provider == 'ollama': - return 'think_tags' + if model.get('owned_by') == 'ollama': + return 'thinking' if provider == 'llama.cpp': return 'reasoning_content' return None +def strip_reasoning_details(output: list) -> list: + return [ + {key: value for key, value in item.items() if key != 'reasoning_details'} if isinstance(item, dict) else item + for item in output + ] + + def process_messages_with_output( messages: list[dict], reasoning_format: str | None = None, @@ -2098,25 +2223,14 @@ def process_messages_with_output( processed.extend(output_messages) continue - # Strip 'output' field before adding (LLM shouldn't see it) - clean_message = {k: v for k, v in message.items() if k != 'output'} + clean_message = dict(message) + for key in ('id', 'files', 'output', 'model', 'contextSummary', 'context_summary', 'usage'): + clean_message.pop(key, None) processed.append(clean_message) return processed -def strip_compaction_fields(messages: list[dict]) -> list[dict]: - stripped = [] - for message in messages: - clean = dict(message) - clean.pop('contextSummary', None) - clean.pop('context_summary', None) - clean.pop('usage', None) - clean.pop('id', None) - stripped.append(clean) - return stripped - - def sanitize_tool_pairs(messages: list[dict]) -> list[dict]: tool_result_ids = { message.get('tool_call_id') @@ -2287,7 +2401,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): model_system_prompt = (form_data.get('params') or {}).get('system') form_data = apply_params_to_form_data(form_data, model) - log.debug(f'form_data: {form_data}') + log.debug('form_data: %s', form_data) # Guided regeneration: extract before it reaches the LLM provider regeneration_prompt = form_data.pop('regeneration_prompt', None) @@ -2306,13 +2420,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): if assistant_message_id: assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id) if assistant_message and (assistant_message.get('content') or assistant_message.get('output')): - db_messages.append( - { - k: v - for k, v in assistant_message.items() - if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage') - } - ) + db_messages.append({k: v for k, v in assistant_message.items() if k in MESSAGE_REPLAY_KEYS}) system_message = get_system_message(form_data.get('messages', [])) form_data['messages'] = [system_message, *db_messages] if system_message else db_messages @@ -2347,7 +2455,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): if is_saved_chat_id(chat_id) and user_message_id: if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): compaction_models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } else: @@ -2375,9 +2483,14 @@ async def process_chat_payload(request, form_data, user, metadata, model): except Exception: log.exception('Context compaction failed; continuing with full chat history') - form_data['messages'] = strip_compaction_fields(form_data.get('messages', [])) - # Process messages with OR-aligned output items for clean LLM messages + for message in form_data.get('messages', []): + output = message.get('output') + # reasoning_details can be model/provider-bound, so only replay them + # for output produced by the same model. + if message.get('role') == 'assistant' and message.get('model') != model['id'] and isinstance(output, list): + message['output'] = strip_reasoning_details(output) + form_data['messages'] = process_messages_with_output( form_data.get('messages', []), reasoning_format=get_reasoning_format(model), @@ -2540,9 +2653,15 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) if 'memory' in features and features['memory'] and await Config.get('memories.system_context.enable'): - form_data = await add_memory_context(request, form_data, user, model) + # features is client-supplied; re-check the permission the native FC path enforces. + if getattr(user, 'role', None) == 'admin' or await has_permission( + getattr(user, 'id', ''), + 'features.memories', + await Config.get('user.permissions'), + ): + form_data = await add_memory_context(request, form_data, user, model) - if 'web_search' in features and features['web_search']: + if 'web_search' in features and features['web_search'] and await Config.get('web.search.enable'): # features is client-supplied; re-check the permission the native FC path enforces. if getattr(user, 'role', None) == 'admin' or await has_permission( getattr(user, 'id', ''), @@ -2617,7 +2736,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): if is_saved_chat_id(metadata.get('chat_id')): chat = await Chats.get_chat_by_id(metadata['chat_id']) - if chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note': + is_note_chat = bool(chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') + + if is_note_chat: note_id = (chat.meta or {}).get('note_id') note = await Notes.get_note_by_id(note_id) if note_id else None if note and ( @@ -2640,9 +2761,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): if note_files: files = [*(files or []), *note_files] - use_builtin_tools = ( - chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note' - ) or ( + use_builtin_tools = is_note_chat or ( bool(metadata.get('session_id')) and metadata.get('params', {}).get('function_calling') != 'legacy' and (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('builtin_tools', True) @@ -2651,9 +2770,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): if skill_ids: from open_webui.models.skills import Skills as SkillsModel - # Reuse the rows from the access query instead of re-fetching each - # skill by id. - accessible_skills = {s.id: s for s in await SkillsModel.get_skills_by_user_id(user.id, 'read')} + accessible_skills = {s.id: s for s in await SkillsModel.get_skills(user_id=user.id, ids=skill_ids)} for sid in skill_ids: s = accessible_skills.get(sid) if s and s.is_active: @@ -2726,8 +2843,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Client side tools direct_tool_servers = metadata.get('tool_servers', None) - log.debug(f'{tool_ids=}') - log.debug(f'{direct_tool_servers=}') + log.debug('tool_ids=%r', tool_ids) + log.debug('direct_tool_servers=%r', direct_tool_servers) tools_dict = {} @@ -2894,6 +3011,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): }, features, model, + is_note_chat=is_note_chat, ) for name, tool_dict in builtin_tools.items(): if name not in tools_dict: @@ -3020,6 +3138,306 @@ async def build_chat_response_context(request, form_data, user, model, metadata, } +async def execute_tool_call_for_output(request, form_data, user, metadata, event_caller, event_emitter, tool_call): + tools = metadata.get('tools', {}) + name = tool_call.get('function', {}).get('name', '') + tool_args = tool_call.get('function', {}).get('arguments', '{}') + params = {} + if tool_args and tool_args.strip(): + try: + params = JSONCodec.loads(tool_args) + except Exception: + try: + params = ast.literal_eval(tool_args) + except Exception as e: + log.debug(e) + return { + 'tool_call_id': tool_call.get('id', ''), + 'content': ( + 'Error: Tool call arguments could not be parsed. ' + 'The model generated malformed or incomplete JSON.' + ), + } + tool_call.setdefault('function', {})['arguments'] = JSONCodec.dumps(params) + + tool = tools.get(name) + if not tool: + return {'tool_call_id': tool_call.get('id', ''), 'content': f'Error: Tool "{name}" not found.'} + + spec = tool.get('spec', {}) + tool_type = tool.get('type', '') + direct_tool = tool.get('direct', False) + allowed_params = spec.get('parameters', {}).get('properties', {}).keys() + params = {key: value for key, value in params.items() if key in allowed_params} + + try: + if direct_tool: + if not event_caller: + result = 'Error: Browser session is not connected for this direct tool.' + else: + result = await event_caller( + { + 'type': 'execute:tool', + 'data': { + 'id': str(uuid4()), + 'name': name, + 'params': params, + 'server': tool.get('server', {}), + 'session_id': metadata.get('session_id'), + }, + } + ) + else: + function = await get_updated_tool_function( + function=tool['callable'], + extra_params={ + '__messages__': form_data.get('messages', []), + '__files__': metadata.get('files', []), + }, + ) + result = await function(**params) + except Exception as e: + result = {'error': str(e)} + + terminal_file_result = build_terminal_file_tool_result(name, params, result, tool, metadata) + if terminal_file_result: + result = terminal_file_result + + result, files, embeds = await process_tool_result( + request, + name, + result, + tool_type, + direct_tool, + metadata, + user, + ) + + await terminal_event_handler(name, params, result, event_emitter) + + return { + 'tool_call_id': tool_call.get('id', ''), + 'content': tool_result_content(result), + **({'files': files} if files else {}), + **({'embeds': embeds} if embeds else {}), + } + + +async def drain_approved_tool_calls(request, form_data, user, model, metadata) -> bool: + chat_id = metadata.get('chat_id') + message_id = metadata.get('message_id') or metadata.get('assistant_message_id') + if not is_saved_chat_id(chat_id) or not message_id: + return False + + message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) + output = message.get('output') if message else None + if not isinstance(output, list): + return False + + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + approved_calls = [ + item + for item in output + if item.get('type') == 'function_call' + and item.get('call_id') + and item.get('status') == 'queued' + and item.get('approved') is True + and item.get('call_id') not in result_call_ids + ] + if not approved_calls: + if metadata.get('params', {}).get('tool_approval_mode', 'full') == 'ask' and any( + item.get('type') == 'function_call' + and item.get('name') != 'ask_user' + and (item.get('call_id') or item.get('id')) + and item.get('status') == 'queued' + and item.get('approved') is not True + and (item.get('call_id') or item.get('id')) not in result_call_ids + for item in output + ): + event_emitter, _ = await get_event_emitter_and_caller(metadata) + await pause_for_tool_approval(chat_id, message_id, output, form_data, metadata) + if event_emitter: + await event_emitter({'type': 'chat:completion', 'data': {'done': False, 'output': output}}) + return True + return False + + event_emitter, event_caller = await get_event_emitter_and_caller(metadata) + changed = False + for item in approved_calls: + if item.get('name') == 'ask_user': + item['status'] = 'pending' + item.pop('approved', None) + changed = True + continue + + tool_call = { + 'id': item.get('call_id', ''), + 'type': 'function', + 'function': { + 'name': item.get('name', ''), + 'arguments': item.get('arguments', '{}'), + }, + } + result = await execute_tool_call_for_output( + request, + form_data, + user, + metadata, + event_caller, + event_emitter, + tool_call, + ) + item['arguments'] = tool_call.get('function', {}).get('arguments', '{}') + output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] + item['status'] = 'failed' if _is_tool_result_error(result.get('content', '')) else 'completed' + display_files = [] + for file_item in result.get('files', []): + if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'): + output_parts.append({'type': 'input_image', 'image_url': file_item['url']}) + else: + display_files.append(file_item) + + output.append( + { + 'type': 'function_call_output', + 'id': output_id('fco'), + 'call_id': result.get('tool_call_id', ''), + 'output': output_parts, + 'status': item['status'], + **({'files': display_files} if display_files else {}), + **({'embeds': result.get('embeds')} if result.get('embeds') else {}), + } + ) + changed = True + + if changed: + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + if metadata.get('params', {}).get('tool_approval_mode', 'full') == 'ask' and any( + item.get('type') == 'function_call' + and item.get('name') != 'ask_user' + and (item.get('call_id') or item.get('id')) + and item.get('status') == 'queued' + and item.get('approved') is not True + and (item.get('call_id') or item.get('id')) not in result_call_ids + for item in output + ): + await pause_for_tool_approval(chat_id, message_id, output, form_data, metadata) + result_call_ids = { + item.get('call_id') + for item in output + if item.get('type') == 'function_call_output' and item.get('call_id') + } + paused = any( + item.get('type') == 'function_call' + and item.get('call_id') + and item.get('status') in {'pending', 'queued', 'requires_approval'} + and item.get('call_id') not in result_call_ids + for item in output + ) + if not paused: + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], + } + ) + + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + message_id, + {'done': False, 'output': output}, + touch=False, + ) + if event_emitter: + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'done': False, + 'output': output, + }, + } + ) + + db_messages = await load_messages_from_db(chat_id, metadata.get('user_message_id')) + if db_messages: + assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) + if assistant_message: + db_messages.append({k: v for k, v in assistant_message.items() if k in MESSAGE_REPLAY_KEYS}) + for message in db_messages: + output = message.get('output') + # reasoning_details can be model/provider-bound, so only replay them + # for output produced by the same model. + if ( + message.get('role') == 'assistant' + and message.get('model') != model['id'] + and isinstance(output, list) + ): + message['output'] = strip_reasoning_details(output) + + form_data['messages'] = process_messages_with_output( + db_messages, + reasoning_format=get_reasoning_format(model), + ) + form_data['messages'] = sanitize_tool_pairs(form_data['messages']) + + return paused + + return False + + +async def pause_for_tool_approval(chat_id: str, message_id: str, output: list[dict], form_data: dict, metadata: dict): + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + has_pending_approval = False + for item in output: + if item.get('type') == 'function_call' and not item.get('call_id') and item.get('id'): + item['call_id'] = item['id'] + + if ( + item.get('type') == 'function_call' + and item.get('call_id') + and item.get('call_id') not in result_call_ids + and item.get('status') != 'rejected' + ): + if not has_pending_approval: + item['status'] = 'pending' + has_pending_approval = True + elif item.get('status') == 'in_progress': + item['status'] = 'queued' + + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + message_id, + { + 'done': False, + 'output': output, + 'meta': { + **(metadata.get('tool_approval') or {}), + 'session_id': metadata.get('session_id'), + 'tool_ids': metadata.get('tool_ids') or [], + 'skill_ids': metadata.get('skill_ids') or [], + 'terminal_id': metadata.get('terminal_id'), + 'tool_servers': metadata.get('tool_servers'), + 'filter_ids': metadata.get('filter_ids') or [], + 'features': metadata.get('features') or {}, + 'variables': metadata.get('variables') or {}, + 'files': metadata.get('files') or [], + 'params': metadata.get('params') or {}, + }, + }, + touch=False, + ) + + def get_response_data(response): if isinstance(response, list) and len(response) == 1: # If the response is a single-item list, unwrap it #17213 @@ -3029,7 +3447,7 @@ def get_response_data(response): if isinstance(response.body, bytes): try: response_data = JSONCodec.loads(response.body.decode('utf-8', 'replace')) - except json.JSONDecodeError: + except JSONCodec.JSONDecodeError: response_data = {'error': {'detail': 'Invalid JSON response'}} else: response_data = response @@ -3414,7 +3832,8 @@ async def outlet_filter_handler(ctx): Replaces the separate POST /api/chat/completed round-trip. Persists outlet-modified content to DB and emits a chat:outlet event - so the frontend can sync its in-memory state. + so the frontend can sync its in-memory state. Returns immediately when + the model has no filters. For temp/API chats, messages are built from form_data plus ctx['assistant_message']. """ @@ -3436,6 +3855,17 @@ async def outlet_filter_handler(ctx): is_unsaved_chat = not is_saved_chat_id(chat_id) try: + filter_functions = ( + await get_filter_functions(request, model, metadata.get('filter_ids', [])) if ENABLE_PLUGINS else [] + ) + model_id = model.get('id') if isinstance(model, dict) else model + models = request.app.state.MODELS + has_pipeline_outlet_filters = bool( + (isinstance(model, dict) and 'pipeline' in model) or get_sorted_filters(model_id, models) + ) + if not filter_functions and not has_pipeline_outlet_filters: + return + messages_map = None if is_unsaved_chat: @@ -3470,8 +3900,6 @@ async def outlet_filter_handler(ctx): if not message_list: return - model_id = model.get('id') if isinstance(model, dict) else model - outlet_data = { 'model': model_id, 'messages': [ @@ -3495,11 +3923,10 @@ async def outlet_filter_handler(ctx): } # Pipeline outlet filters - models = request.app.state.MODELS try: outlet_data = await process_pipeline_outlet_filter(request, outlet_data, user, models) except Exception as e: - log.debug(f'Pipeline outlet filter error: {e}') + log.debug('Pipeline outlet filter error: %s', e) # Function outlet filters extra_params = { @@ -3511,9 +3938,7 @@ async def outlet_filter_handler(ctx): '__model__': model, } - if ENABLE_PLUGINS: - filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', [])) - + if filter_functions: outlet_result, _ = await process_filter_functions( request=request, filter_context=None, @@ -3560,7 +3985,7 @@ async def outlet_filter_handler(ctx): } ) except Exception as e: - log.debug(f'Error running outlet filters: {e}') + log.debug('Error running outlet filters: %s', e) async def non_streaming_chat_response_handler(response, ctx): @@ -3637,7 +4062,7 @@ async def non_streaming_chat_response_handler(response, ctx): if not response_output: choice_message = choices[0].get('message', {}) reasoning_content = choice_message.get('reasoning_content') or choice_message.get('reasoning') - reasoning_details = choice_message.get('reasoning_details') + reasoning_details = get_reasoning_details(choice_message) response_output = [] if reasoning_content or reasoning_details: reasoning_item = { @@ -3705,7 +4130,7 @@ async def non_streaming_chat_response_handler(response, ctx): response = build_response_object(response, merge_events_into_response(response_data, events)) except Exception as e: - log.debug(f'Error occurred while processing request: {e}') + log.debug('Error occurred while processing request: %s', e) chat_id = metadata.get('chat_id') if getattr(request.state, 'internal', False) is not True and chat_id and is_saved_chat_id(chat_id): webui_url = await Config.get('webui.url') @@ -3788,6 +4213,8 @@ async def streaming_chat_response_handler(response, ctx): async def response_handler(response, events): filter_context = FilterContext() tag_scan_positions = {} + tag_boundary_positions = {} + response_stream_task_id = metadata.get('task_id') or metadata.get('message_id') def tag_output_handler(content_type, tags, output): """ @@ -3842,6 +4269,29 @@ async def streaming_chat_response_handler(response, ctx): item_id = item.get('id') if item_id: tag_scan_positions.pop((item_id, content_type), None) + tag_boundary_positions.pop((item_id, content_type), None) + + def get_tag_boundaries(item, text, scanned_length): + """Index of the last '<', and of the last '>' or newline, before scanned_length.""" + key = (item.get('id'), content_type) + scanned, last_open, last_boundary = tag_boundary_positions.get(key, (0, -1, -1)) + if scanned > scanned_length: # the item was rewritten, so the cached positions are stale + scanned, last_open, last_boundary = 0, -1, -1 + + if scanned < scanned_length: + # only text added since the last call can move either position + open_tag = text.rfind('<', scanned, scanned_length) + if open_tag != -1: + last_open = open_tag + boundary = max( + text.rfind('>', scanned, scanned_length), + text.rfind('\n', scanned, scanned_length), + ) + if boundary != -1: + last_boundary = boundary + tag_boundary_positions[key] = (scanned_length, last_open, last_boundary) + + return last_open, last_boundary # Map content_type to output item type output_type_map = { @@ -3864,11 +4314,7 @@ async def streaming_chat_response_handler(response, ctx): if scanned_length and any( start_tag.startswith('<') and start_tag.endswith('>') for start_tag, _ in tags ): - last_tag_boundary = max( - item_text.rfind('>', 0, scanned_length), - item_text.rfind('\n', 0, scanned_length), - ) - open_tag_start = item_text.rfind('<', 0, scanned_length) + open_tag_start, last_tag_boundary = get_tag_boundaries(item, item_text, scanned_length) if open_tag_start > last_tag_boundary: search_start = min(search_start, open_tag_start) @@ -4070,7 +4516,20 @@ async def streaming_chat_response_handler(response, ctx): # Initialize output: use existing from message if continuing, else create new existing_output = message.get('output') if message else None - if existing_output: + prior_output = [] + if existing_output and metadata.get('assistant_message_id'): + prior_output = list(existing_output) + if ( + prior_output + and prior_output[-1].get('type') == 'message' + and prior_output[-1].get('status') == 'in_progress' + ): + msg_parts = prior_output[-1].get('content', []) + if not msg_parts or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()): + prior_output.pop() + output = [] + content_parts = [] + elif existing_output: output = existing_output else: # Only create an initial message item if there is content to initialize with @@ -4088,7 +4547,6 @@ async def streaming_chat_response_handler(response, ctx): output = [] usage = None - prior_output = [] last_response_id = None def full_output(): @@ -4121,13 +4579,14 @@ async def streaming_chat_response_handler(response, ctx): reasoning_tags_param = metadata.get('params', {}).get('reasoning_tags') DETECT_REASONING_TAGS = reasoning_tags_param is not False - # Mirror the five gates from utils/tools.py get_builtin_tools so the - # legacy XML-tag path enforces the same authz as native FC. + # Legacy tool-calling only: native FC gets execute_code as a builtin tool. + # Same five authz gates as utils/tools.py get_builtin_tools. features = metadata.get('features', {}) or {} model_capabilities = model.get('info', {}).get('meta', {}).get('capabilities') or {} builtin_tools_meta = model.get('info', {}).get('meta', {}).get('builtinTools', {}) DETECT_CODE_INTERPRETER = ( - bool(features.get('code_interpreter')) + metadata.get('params', {}).get('function_calling') == 'legacy' + and bool(features.get('code_interpreter')) and builtin_tools_meta.get('code_interpreter', True) and await Config.get('code_interpreter.enable') and model_capabilities.get('code_interpreter', True) @@ -4168,7 +4627,6 @@ async def streaming_chat_response_handler(response, ctx): ) async def stream_body_handler(response, form_data): - nonlocal content_parts nonlocal usage nonlocal output nonlocal prior_output @@ -4183,38 +4641,118 @@ async def streaming_chat_response_handler(response, ctx): ) last_delta_data = None last_delta_type = None + last_delta_key = None + + joined_content = '' + joined_part_count = 0 + + async def save_current_response_stream(stream_output: list | None = None): + nonlocal joined_content + nonlocal joined_part_count + + if not chat_id or not metadata.get('message_id'): + return + + # content_parts is append-only, so its length tells us when the join is stale + if joined_part_count != len(content_parts): + joined_content = ''.join(content_parts) + joined_part_count = len(content_parts) + + current_stream_output = stream_output if stream_output is not None else full_output() + await save_response_stream( + request.app.state.redis, + response_stream_task_id, + chat_id, + metadata.get('message_id'), + joined_content or get_output_text(current_stream_output), + current_stream_output, + ) + + def get_response_delta_key(delta_data: dict): + event_type = delta_data.get('type', '') + if not event_type.startswith('response.') or not event_type.endswith('.delta'): + return None + return ( + event_type, + delta_data.get('item_id'), + delta_data.get('output_index'), + delta_data.get('content_index'), + delta_data.get('summary_index'), + ) + + def get_response_data_with_full_output_index(response_data: dict): + if prior_output and isinstance(response_data.get('output_index'), int): + return { + **response_data, + 'output_index': response_data['output_index'] + len(prior_output), + } + return response_data async def flush_pending_delta_data(threshold: int = 0): nonlocal delta_count nonlocal last_delta_data nonlocal last_delta_type + nonlocal last_delta_key if delta_count >= threshold and last_delta_data: await event_emitter( { - 'type': 'chat:completion', + 'type': 'response:completion', 'data': last_delta_data, } ) + await save_current_response_stream() delta_count = 0 last_delta_data = None last_delta_type = None + last_delta_key = None async def queue_pending_delta_data(delta_data: dict, delta_type: str): nonlocal delta_count nonlocal last_delta_data nonlocal last_delta_type + nonlocal last_delta_key - if last_delta_type and last_delta_type != delta_type: - await flush_pending_delta_data() + delta_data = get_response_data_with_full_output_index(delta_data) + delta_key = get_response_delta_key(delta_data) + if ( + last_delta_data + and last_delta_key == delta_key + and isinstance(last_delta_data.get('delta'), str) + and isinstance(delta_data.get('delta'), str) + ): + last_delta_data['delta'] += delta_data['delta'] + delta_count += 1 + else: + if last_delta_data and (last_delta_type != delta_type or last_delta_key != delta_key): + await flush_pending_delta_data() - delta_count += 1 - last_delta_data = delta_data - last_delta_type = delta_type + delta_count += 1 + last_delta_data = delta_data + last_delta_type = delta_type + last_delta_key = delta_key if delta_count >= delta_chunk_size: await flush_pending_delta_data(delta_chunk_size) + async def emit_response_completion_event(response_data: dict, stream_output: list | None = None): + if response_data.get('type', '').endswith('.delta'): + await queue_pending_delta_data( + response_data, + response_data.get('type', 'response.delta'), + ) + return + + response_data = get_response_data_with_full_output_index(response_data) + await flush_pending_delta_data() + await event_emitter( + { + 'type': 'response:completion', + 'data': get_response_completion_event_data(response_data), + } + ) + await save_current_response_stream(stream_output) + filter_extra_params = {'__body__': form_data, **extra_params} if filter_functions else None async for line in response.body_iterator: @@ -4289,16 +4827,16 @@ async def streaming_chat_response_handler(response, ctx): ) # Check for Responses API events (type field starts with "response.") elif data.get('type', '').startswith('response.'): - response_event_type = data.get('type', '') - response_event_is_delta = response_event_type.endswith('.delta') + response_data_type = data.get('type', '') + response_data_is_delta = response_data_type.endswith('.delta') output, response_metadata = handle_responses_streaming_event(data, output) - if not response_event_is_delta: + if not response_data_is_delta: await flush_pending_delta_data() # Emit citation sources from finalized output items # (mirrors Chat Completions annotation handling at delta level) - if response_event_type == 'response.output_item.done': + if response_data_type == 'response.output_item.done': item = data.get('item', {}) if item.get('type') == 'message': for part in item.get('content', []): @@ -4330,13 +4868,6 @@ async def streaming_chat_response_handler(response, ctx): } ) - processed_data = { - 'output': full_output(), - } - - # print(data) - # print(processed_data) - # Merge any metadata (usage, etc.) # Strip 'done' — response.completed emits # it but we may still need to execute tool @@ -4353,22 +4884,21 @@ async def streaming_chat_response_handler(response, ctx): usage = merge_usage(usage, response_metadata['usage']) response_metadata['usage'] = usage - processed_data.update(response_metadata) - processed_data.pop('done', None) + if response_metadata.get('error'): + await event_emitter( + { + 'type': 'chat:completion', + 'data': {'error': response_metadata['error']}, + } + ) - if response_event_is_delta: - response_delta_type = response_event_type.split('.')[1] - await queue_pending_delta_data( - processed_data, - 'tool_call' - if response_delta_type == 'function_call_arguments' - else 'content', - ) - else: + await emit_response_completion_event(data) + + if response_metadata and response_metadata.get('usage'): await event_emitter( { 'type': 'chat:completion', - 'data': processed_data, + 'data': {'usage': usage}, } ) continue @@ -4466,12 +4996,13 @@ async def streaming_chat_response_handler(response, ctx): # Add the new tool call delta_tool_call.setdefault('function', {}) delta_tool_call['function'].setdefault('name', '') + delta_tool_call['id'] = delta_tool_call.get('id') or output_id('fc') delta_arguments = delta_tool_call['function'].get('arguments') if not isinstance(delta_arguments, str): delta_tool_call['function']['arguments'] = ( '' if delta_arguments is None - else json.dumps(delta_arguments) + else JSONCodec.dumps(delta_arguments) ) response_tool_calls.append(delta_tool_call) else: @@ -4486,7 +5017,7 @@ async def streaming_chat_response_handler(response, ctx): if delta_arguments is not None: if not isinstance(delta_arguments, str): - delta_arguments = json.dumps(delta_arguments) + delta_arguments = JSONCodec.dumps(delta_arguments) current_response_tool_call.setdefault('function', {}) if not isinstance( current_response_tool_call['function'].get('arguments'), @@ -4497,27 +5028,73 @@ async def streaming_chat_response_handler(response, ctx): delta_arguments ) - # Emit pending tool calls in real-time + # Emit pending tool calls in real-time as Responses events. if response_tool_calls: - # Build pending function_call output items for display - pending_fc_items = [] + output_by_call_id = { + item.get('call_id'): (idx, item) + for idx, item in enumerate(output) + if item.get('type') == 'function_call' + } + for tc in response_tool_calls: - call_id = tc.get('id', '') + call_id = tc.get('id') or output_id('fc') + tc['id'] = call_id func = tc.get('function', {}) - pending_fc_items.append( - { + if call_id in output_by_call_id: + output_index, item = output_by_call_id[call_id] + item['name'] = func.get('name', item.get('name', '')) + item['arguments'] = func.get('arguments', item.get('arguments', '')) + item['status'] = 'in_progress' + else: + output_index = len(output) + item = { 'type': 'function_call', - 'id': call_id or output_id('fc'), + 'id': call_id, 'call_id': call_id, 'name': func.get('name', ''), - 'arguments': func.get('arguments', '{}'), + 'arguments': '', 'status': 'in_progress', } - ) + output.append(item) + output_by_call_id[call_id] = (output_index, item) + await emit_response_completion_event( + { + 'type': 'response.output_item.added', + 'output_index': output_index, + 'item': item.copy(), + } + ) + item['arguments'] = func.get('arguments', '') - data = { - 'output': full_output() + pending_fc_items, - } + for delta_tool_call in delta_tool_calls: + tool_call_index = delta_tool_call.get('index') + current_response_tool_call = next( + ( + tc + for tc in response_tool_calls + if tc.get('index') == tool_call_index + ), + None, + ) + if not current_response_tool_call: + continue + call_id = current_response_tool_call.get('id') + output_index, _ = output_by_call_id.get(call_id, (len(output) - 1, {})) + delta_arguments = delta_tool_call.get('function', {}).get('arguments') + if delta_arguments is not None: + if not isinstance(delta_arguments, str): + delta_arguments = JSONCodec.dumps(delta_arguments) + await emit_response_completion_event( + { + 'type': 'response.function_call_arguments.delta', + 'item_id': call_id, + 'output_index': output_index, + 'delta': delta_arguments, + } + ) + + await save_current_response_stream() + data = None delta_type = 'tool_call' delta_images = delta.get('images') @@ -4545,14 +5122,19 @@ async def streaming_chat_response_handler(response, ctx): } ) + # content and reasoning deltas are raw JSON: a stream filter can make them any type value = delta.get('content') + if value and not isinstance(value, str): + value = f'{value}' reasoning_content = ( delta.get('reasoning_content') or delta.get('reasoning') or delta.get('thinking') ) - reasoning_details = delta.get('reasoning_details') + if reasoning_content and not isinstance(reasoning_content, str): + reasoning_content = f'{reasoning_content}' + reasoning_details = get_reasoning_details(delta) reasoning_detail_items = ( [item for item in reasoning_details if isinstance(item, dict)] if isinstance(reasoning_details, list) @@ -4621,20 +5203,26 @@ async def streaming_chat_response_handler(response, ctx): } ] + reasoning_index = output.index(reasoning_item) data = { - 'output': full_output(), + 'type': 'response.reasoning_text.delta', + 'item_id': reasoning_item.get('id'), + 'output_index': reasoning_index, + 'content_index': max( + len(reasoning_item.get('content', [])) - 1, + 0, + ), + 'delta': reasoning_content, } - delta_type = 'content' + delta_type = 'response.reasoning_text.delta' if reasoning_detail_items: merge_streamed_reasoning_details( reasoning_item.setdefault('reasoning_details', []), reasoning_detail_items, ) - data = { - 'output': full_output(), - } - delta_type = 'content' + await save_current_response_stream() + data = None if value: if ( @@ -4676,7 +5264,7 @@ async def streaming_chat_response_handler(response, ctx): ) # closure-cell str += recopies per chunk; append + join once at read is O(n) - content_parts.append(value if isinstance(value, str) else f'{value}') + content_parts.append(value) # Check if we're inside a tag-based block # (reasoning, code_interpreter, or solution). @@ -4780,29 +5368,27 @@ async def streaming_chat_response_handler(response, ctx): if end: break - if ENABLE_REALTIME_CHAT_SAVE and save_to_chat: - current_output = full_output() - # Save message in the database - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'output': current_output, - }, - ) - data = { - 'output': current_output, - } - delta_type = 'content' - else: - data = { - 'output': full_output(), - } - delta_type = 'content' + target_index = len(output) - 1 + target_item = output[target_index] if target_index >= 0 else {} + target_content = target_item.get('content', []) + content_index = max(len(target_content) - 1, 0) + delta_event_type = ( + 'response.reasoning_text.delta' + if target_item.get('type') == 'reasoning' + else 'response.output_text.delta' + ) + data = { + 'type': delta_event_type, + 'item_id': target_item.get('id'), + 'output_index': target_index, + 'content_index': content_index, + 'delta': value, + } + delta_type = delta_event_type - if delta: + if delta and data: await queue_pending_delta_data(data, delta_type) - else: + elif data: await event_emitter( { 'type': 'chat:completion', @@ -4816,7 +5402,7 @@ async def streaming_chat_response_handler(response, ctx): if done: pass else: - log.debug(f'Error: {e}') + log.debug('Error: %s', e) continue await flush_pending_delta_data() @@ -4845,12 +5431,36 @@ async def streaming_chat_response_handler(response, ctx): reasoning_item = output[-1] if reasoning_item.get('ended_at') is None: reasoning_item['ended_at'] = time.time() - reasoning_item['duration'] = int( - reasoning_item['ended_at'] - reasoning_item['started_at'] - ) + if reasoning_item.get('started_at') is not None: + reasoning_item['duration'] = int( + reasoning_item['ended_at'] - reasoning_item['started_at'] + ) reasoning_item['status'] = 'completed' if response_tool_calls: + for tc in response_tool_calls: + call_id = tc.get('id', '') + arguments = tc.get('function', {}).get('arguments', '{}') + for output_index, item in enumerate(output): + if item.get('type') == 'function_call' and item.get('call_id') == call_id: + item['arguments'] = arguments + item['status'] = 'completed' + await emit_response_completion_event( + { + 'type': 'response.function_call_arguments.done', + 'item_id': item.get('id'), + 'output_index': output_index, + 'arguments': arguments, + } + ) + await emit_response_completion_event( + { + 'type': 'response.output_item.done', + 'output_index': output_index, + 'item': item.copy(), + } + ) + break tool_calls.append(_split_tool_calls(response_tool_calls)) # Responses API path: extract function_call items from output @@ -4865,16 +5475,17 @@ async def streaming_chat_response_handler(response, ctx): } responses_api_tool_calls = [] for item in output: - if item.get('type') == 'function_call' and item.get('call_id') not in handled_call_ids: + call_id = item.get('call_id') or item.get('id') or output_id('fc') + if item.get('type') == 'function_call' and call_id not in handled_call_ids: arguments = item.get('arguments', '{}') responses_api_tool_calls.append( { - 'id': item.get('call_id', ''), + 'id': call_id, 'index': len(responses_api_tool_calls), 'function': { 'name': item.get('name', ''), 'arguments': ( - arguments if isinstance(arguments, str) else json.dumps(arguments) + arguments if isinstance(arguments, str) else JSONCodec.dumps(arguments) ), }, } @@ -4919,6 +5530,22 @@ async def streaming_chat_response_handler(response, ctx): tool_call_iterations += 1 response_tool_calls = tool_calls.pop(0) + ask_user_stage = stage_ask_user_tool_call(response_tool_calls, output, output_id) + if ask_user_stage: + if ask_user_stage['error']: + await event_emitter({'type': 'chat:completion', 'data': {'output': full_output()}}) + continue + + if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'): + await pause_for_tool_approval( + metadata['chat_id'], + metadata['message_id'], + full_output(), + form_data, + metadata, + ) + await event_emitter({'type': 'chat:completion', 'data': {'output': full_output()}}) + return # Append function_call items for each tool call # (Responses API already has them from streaming, so skip duplicates) @@ -4938,6 +5565,29 @@ async def streaming_chat_response_handler(response, ctx): } ) + tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full') + if ( + tool_approval_mode == 'ask' + and is_saved_chat_id(metadata.get('chat_id')) + and metadata.get('message_id') + ): + await pause_for_tool_approval( + metadata['chat_id'], + metadata['message_id'], + full_output(), + form_data, + metadata, + ) + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'output': full_output(), + }, + } + ) + return + await event_emitter( { 'type': 'chat:completion', @@ -4963,7 +5613,7 @@ async def streaming_chat_response_handler(response, ctx): except Exception as e: log.debug(e) return None - tool_call.setdefault('function', {})['arguments'] = json.dumps(params) + tool_call.setdefault('function', {})['arguments'] = JSONCodec.dumps(params) return params async def execute_tool_call(tool_call): @@ -5003,7 +5653,7 @@ async def streaming_chat_response_handler(response, ctx): ) result = await function(**params) except Exception as e: - result = str(e) + result = {'error': str(e)} return params, result, tool, tool_type, direct_tool delegate_calls = [ @@ -5038,6 +5688,16 @@ async def streaming_chat_response_handler(response, ctx): ) continue + terminal_file_result = build_terminal_file_tool_result( + tool_function_name, + tool_function_params, + tool_result, + tool, + metadata, + ) + if terminal_file_result: + tool_result = terminal_file_result + tool_result, tool_result_files, tool_result_embeds = await process_tool_result( request, tool_function_name, @@ -5083,25 +5743,19 @@ async def streaming_chat_response_handler(response, ctx): results.append( { 'tool_call_id': tool_call_id, - 'content': str(tool_result) if tool_result else '', + 'content': tool_result_content(tool_result), **({'files': tool_result_files} if tool_result_files else {}), **({'embeds': tool_result_embeds} if tool_result_embeds else {}), } ) - # Update function_call statuses and append function_call_output items - for tc in response_tool_calls: - call_id = tc.get('id', '') - # Mark function_call as completed - for item in output: - if item.get('type') == 'function_call' and item.get('call_id') == call_id: - item['status'] = 'completed' - # Update arguments with parsed/sanitized version - item['arguments'] = tc.get('function', {}).get('arguments', '{}') - break - + result_status_by_call_id = {} for result in results: output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] + local_output_status = ( + 'failed' if _is_tool_result_error(result.get('content', '')) else 'completed' + ) + result_status_by_call_id[result.get('tool_call_id', '')] = local_output_status # Separate image data URIs (for LLM via input_image) from # other files (for frontend display via files attribute). @@ -5120,12 +5774,21 @@ async def streaming_chat_response_handler(response, ctx): 'id': output_id('fco'), 'call_id': result.get('tool_call_id', ''), 'output': output_parts, - 'status': 'completed', + 'status': local_output_status, **({'files': display_files} if display_files else {}), **({'embeds': result.get('embeds')} if result.get('embeds') else {}), } ) + # Update function_call statuses and parsed/sanitized arguments. + for tc in response_tool_calls: + call_id = tc.get('id', '') + for item in output: + if item.get('type') == 'function_call' and item.get('call_id') == call_id: + item['status'] = result_status_by_call_id.get(call_id, 'completed') + item['arguments'] = tc.get('function', {}).get('arguments', '{}') + break + # Append a new empty message item for the next response output.append( { @@ -5203,7 +5866,7 @@ async def streaming_chat_response_handler(response, ctx): # output sent to the frontend — they're only for LLM consumption # via convert_output_to_messages. frontend_output = [] - for item in output: + for item in full_output(): if item.get('type') == 'function_call_output': parts = item.get('output', []) if any(p.get('type') == 'input_image' for p in parts): @@ -5289,7 +5952,7 @@ async def streaming_chat_response_handler(response, ctx): # keeps indices aligned. The display prefix # ensures the UI shows tool history during # streaming. - prior_output = list(output) + prior_output = list(full_output()) # Trim the trailing empty placeholder message # so it doesn't persist as a ghost item once # the new stream produces real content. @@ -5305,6 +5968,9 @@ async def streaming_chat_response_handler(response, ctx): await stream_body_handler(res, new_form_data) output[:0] = prior_output prior_output = [] + elif getattr(res, 'status_code', 200) >= 400: + await emit_message_error(get_message_error_content(get_response_error_detail(res))) + break else: break except Exception as e: @@ -5331,13 +5997,13 @@ async def streaming_chat_response_handler(response, ctx): { 'type': 'chat:completion', 'data': { - 'output': output, + 'output': full_output(), }, } ) retries += 1 - log.debug(f'Attempt count: {retries}') + log.debug('Attempt count: %s', retries) ci_item = output[-1] ci_output = '' @@ -5399,7 +6065,7 @@ async def streaming_chat_response_handler(response, ctx): else: ci_output = {'stdout': 'Code interpreter engine not configured.'} - log.debug(f'Code interpreter output: {ci_output}') + log.debug('Code interpreter output: %s', ci_output) # Handle error responses from event_caller # (e.g. session disconnected, timeout) @@ -5458,7 +6124,7 @@ async def streaming_chat_response_handler(response, ctx): { 'type': 'chat:completion', 'data': { - 'output': output, + 'output': full_output(), }, } ) @@ -5489,6 +6155,9 @@ async def streaming_chat_response_handler(response, ctx): if isinstance(res, StreamingResponse): await stream_body_handler(res, new_form_data) + elif getattr(res, 'status_code', 200) >= 400: + await emit_message_error(get_message_error_content(get_response_error_detail(res))) + break else: break except Exception as e: @@ -5502,40 +6171,32 @@ async def streaming_chat_response_handler(response, ctx): if item.get('status') == 'in_progress': item['status'] = 'completed' + current_output = full_output() title = await Chats.get_chat_title_by_id(metadata['chat_id']) if save_to_chat else '' data = { 'done': True, - 'output': output, + 'output': current_output, 'title': title, **({'usage': usage} if usage else {}), } if save_to_chat: - if not ENABLE_REALTIME_CHAT_SAVE: - # Save message in the database - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'done': True, - 'output': output, - **({'usage': usage} if usage else {}), - }, - ) - elif usage: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - {'done': True, 'usage': usage}, - ) - else: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - {'done': True}, - ) + # Save final output once. The delta path keeps in-progress + # state in response_streams instead of writing tokens to DB. + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'output': current_output, + **({'usage': usage} if usage else {}), + }, + ) - await publish_chat_finished_event(request, user, metadata, title, ''.join(content_parts), output) + await clear_response_stream(request.app.state.redis, response_stream_task_id) + await publish_chat_finished_event( + request, user, metadata, title, ''.join(content_parts), current_output + ) await event_emitter( { @@ -5545,8 +6206,8 @@ async def streaming_chat_response_handler(response, ctx): ) ctx['assistant_message'] = { - 'content': ''.join(content_parts) or get_output_text(output), - 'output': output, + 'content': ''.join(content_parts) or get_output_text(current_output), + 'output': current_output, **({'usage': usage} if usage else {}), } await outlet_filter_handler(ctx) @@ -5567,22 +6228,15 @@ async def streaming_chat_response_handler(response, ctx): async def save_cancelled_state(): await event_emitter({'type': 'chat:tasks:cancel'}) if save_to_chat: - if not ENABLE_REALTIME_CHAT_SAVE: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'done': True, - 'output': output, - }, - ) - else: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - {'done': True}, - touch=False, - ) + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'output': full_output(), + }, + ) + await clear_response_stream(request.app.state.redis, response_stream_task_id) try: await asyncio.shield(save_cancelled_state()) @@ -5628,14 +6282,26 @@ async def streaming_chat_response_handler(response, ctx): yield wrap_item(JSONCodec.dumps(event)) async for data in original_generator: - data, _ = await process_filter_functions( - request=request, - filter_context=filter_context, - filter_functions=filter_functions, - filter_type='stream', - form_data=data, - extra_params=extra_params, - ) + if filter_functions: + line = data.decode('utf-8', 'replace') if isinstance(data, bytes) else data + if isinstance(line, str) and line.startswith('data:'): + payload = line.removeprefix('data:').strip() + if payload and payload != '[DONE]': + try: + event = JSONCodec.loads(payload) + except JSONCodec.JSONDecodeError: + event = None + + if isinstance(event, dict): + event, _ = await process_filter_functions( + request=request, + filter_context=filter_context, + filter_functions=filter_functions, + filter_type='stream', + form_data=event, + extra_params=extra_params, + ) + data = wrap_item(JSONCodec.dumps(event)) if event else None if data: if has_api_outlet_filters: diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 8b867ff2d3..ea15adf75e 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -2,19 +2,21 @@ from __future__ import annotations import collections.abc import hashlib -import json +import ipaddress import logging import re import threading import time import uuid from datetime import timedelta +from functools import lru_cache from pathlib import Path from typing import Callable, Optional, Sequence, Union import aiohttp import mimeparse from open_webui.env import CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) SURROGATE_RE = re.compile('[\ud800-\udfff]') @@ -29,6 +31,40 @@ def deep_update(d, u): return d +def merge_model_params(base: dict, override: dict) -> dict: + params = {**base, **override} + base_custom = base.get('custom_params') + override_custom = override.get('custom_params') + if isinstance(base_custom, dict) and (override_custom is None or isinstance(override_custom, dict)): + params['custom_params'] = {**base_custom, **(override_custom or {})} + return params + + +def get_response_error_detail(response: object) -> str: + status_code = getattr(response, 'status_code', None) + fallback = f'Provider returned HTTP {status_code}' if status_code else 'Provider returned an error' + + try: + body = response.body + if not isinstance(body, str): + body = body.decode('utf-8', 'replace') + detail = JSONCodec.loads(body) + except Exception: + return fallback + + while isinstance(detail, dict): + next_detail = None + for key in ('error', 'message', 'detail'): + if key in detail: + next_detail = detail[key] + break + if next_detail is None: + return str(detail) + detail = next_detail + + return detail if isinstance(detail, str) else str(detail) + + def _strip_filter_entry(entry): # Compose list-form env syntax passes surrounding quotes through verbatim return (entry or '').strip().strip('"\'').strip() @@ -78,17 +114,39 @@ def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str | return True +@lru_cache(maxsize=512) +def as_network(pattern: str) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None: + """A filter entry read as an address range, or None when the entry names a host instead. + + Surrounding whitespace and a trailing dot are stripped here rather than by each caller, + since ip_network rejects both and the callers do not normalise the same way. + """ + try: + return ipaddress.ip_network((pattern or '').strip().lower().rstrip('.'), strict=False) + except ValueError: + return None + + def _host_matches_pattern(host: str, pattern: str) -> bool: """Match a hostname against a filter entry on DNS label boundaries. `pattern` matches `host` when equal or a parent domain of it, so `corp.com` - matches `api.corp.com` but not `evilcorp.com`, and an IP literal matches only - itself. Avoids the raw-suffix confusion of a plain endswith. + matches `api.corp.com` but not `evilcorp.com`. Avoids the raw-suffix confusion + of a plain endswith. + + An entry that names an address or a CIDR range is matched by containment instead, so + `10.0.0.0/8` covers `10.1.2.3` and an address matches any spelling of itself in its own family. """ host = (host or '').strip().lower().rstrip('.') pattern = (pattern or '').strip().lower().rstrip('.') if not host or not pattern: return False + network = as_network(pattern) + if network is not None: + try: + return ipaddress.ip_address(host) in network + except ValueError: + return False # a hostname is never inside an address range return host == pattern or host.endswith('.' + pattern) @@ -99,21 +157,26 @@ def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str | Non Pass a parsed hostname, never a full URL: matching against a URL lets a path component defeat the filter (e.g. ``https://blocked.example/x`` ends with ``/x``, not the blocked host). Entries prefixed with ``!`` are blocked; the rest form an allowlist. + An entry naming an address or a CIDR range is matched by containment instead. """ if not filter_list: return True - allow_list, block_list = get_allow_block_lists(filter_list) + allow_list, _ = get_allow_block_lists(filter_list) hosts = [host] if isinstance(host, str) else list(host or []) if allow_list: if not any(_host_matches_pattern(h, allowed) for h in hosts for allowed in allow_list): return False - if any(_host_matches_pattern(h, blocked) for h in hosts for blocked in block_list): - return False + return not is_host_blocked(hosts, filter_list) - return True + +def is_host_blocked(host: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool: + """Whether a host or resolved address matches a block entry, ignoring any allow entries.""" + _, block_list = get_allow_block_lists(filter_list) + hosts = [host] if isinstance(host, str) else list(host or []) + return any(_host_matches_pattern(h, blocked) for h in hosts for blocked in block_list) def get_message_list(messages_map, message_id): @@ -139,18 +202,13 @@ def get_message_list(messages_map, message_id): message_list = [] visited_message_ids = set() - while current_message: - 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) - + # Track the map keys, not the messages' own 'id' field: a message may omit it + while current_message and message_id not in visited_message_ids: + 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_id = current_message.get('parentId') + current_message = messages_map.get(message_id) if message_id else None message_list.reverse() return message_list @@ -196,7 +254,8 @@ def get_output_text(output: list | None) -> str: text = ''.join( str(part.get('text')) for part in parts if isinstance(part, dict) and part.get('text') is not None ) - if text.strip(): + # isspace() avoids the full-string copy strip() would make + if text and not text.isspace(): texts.append(text) return '\n'.join(texts) @@ -254,6 +313,15 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: return reconciled_messages +def get_reasoning_details(payload: dict): + if not isinstance(payload, dict): + return None + + provider_fields = payload.get('provider_specific_fields') or {} + provider_details = provider_fields.get('reasoning_details') if isinstance(provider_fields, dict) else None + return payload.get('reasoning_details') or provider_details + + def convert_output_to_messages( output: list, raw: bool = False, @@ -273,8 +341,10 @@ def convert_output_to_messages( follow-ups. reasoning_format: How to include reasoning blocks in the output: - None: skip reasoning (default, safe for strict providers). + - ``'thinking'``: set as ``thinking`` top-level field + (for native Ollama). - ``'think_tags'``: wrap in ```` tags inside content - (for Ollama, which expects reasoning as tagged content). + (for legacy providers that expect reasoning as tagged content). - ``'reasoning_content'``: set as ``reasoning_content`` top-level field (for llama.cpp, which routes it via the chat template). flatten_tool_images: Move tool output images into a following user @@ -286,12 +356,21 @@ def convert_output_to_messages( messages = [] pending_tool_calls = [] pending_content = [] - pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content' + pending_reasoning = [] # Only populated for top-level structured reasoning fields. pending_reasoning_details = [] pending_tool_image_urls = [] - function_call_ids = { - item.get('call_id') for item in output if item.get('type') == 'function_call' and item.get('call_id') + pending_tool_outputs = [] + completed_call_ids = { + item.get('call_id') + for item in output + if item.get('type') == 'function_call' + and item.get('call_id') + and item.get('status') in {'completed', 'failed', 'rejected'} } + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + function_call_ids = completed_call_ids & result_call_ids def flush_pending(): nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details @@ -305,7 +384,10 @@ def convert_output_to_messages( } if pending_reasoning: - message['reasoning_content'] = '\n'.join(pending_reasoning) + if reasoning_format == 'thinking': + message['thinking'] = '\n'.join(pending_reasoning) + else: + message['reasoning_content'] = '\n'.join(pending_reasoning) if pending_reasoning_details: message['reasoning_details'] = pending_reasoning_details @@ -335,44 +417,14 @@ def convert_output_to_messages( ) pending_tool_image_urls = [] - for item in output: - item_type = item.get('type', '') - if item_type != 'function_call_output': - flush_tool_images() + def flush_tool_outputs(): + nonlocal pending_tool_outputs + if not pending_tool_outputs: + return - if item_type == 'message': - # Extract text from output_text content parts - content_parts = item.get('content', []) - text = '' - for part in content_parts: - if part.get('type') == 'output_text': - text += part.get('text', '') - if text: - pending_content.append(text) - - elif item_type == 'function_call': - # Collect tool calls to batch into assistant message - arguments = item.get('arguments', '{}') - # Ensure arguments is always a JSON string - if not isinstance(arguments, str): - arguments = json.dumps(arguments) - pending_tool_calls.append( - { - 'id': item.get('call_id', ''), - 'type': 'function', - 'function': { - 'name': item.get('name', ''), - 'arguments': arguments, - }, - } - ) - - elif item_type == 'function_call_output': - # Flush any pending content/tool_calls before adding tool result - flush_pending() - - # Extract text and images from output content parts - output_parts = item.get('output', []) + flush_pending() + for output_item in pending_tool_outputs: + output_parts = output_item.get('output', []) content = '' image_urls = [] for part in output_parts: @@ -388,17 +440,16 @@ def convert_output_to_messages( messages.append( { 'role': 'tool', - 'tool_call_id': item.get('call_id', ''), + 'tool_call_id': output_item.get('call_id', ''), 'content': content, } ) - if item.get('call_id') in function_call_ids: - pending_tool_image_urls.extend(image_urls) + pending_tool_image_urls.extend(image_urls) elif image_urls: messages.append( { 'role': 'tool', - 'tool_call_id': item.get('call_id', ''), + 'tool_call_id': output_item.get('call_id', ''), 'content': [ {'type': 'input_text', 'text': content}, *[{'type': 'input_image', 'image_url': url} for url in image_urls], @@ -409,13 +460,65 @@ def convert_output_to_messages( messages.append( { 'role': 'tool', - 'tool_call_id': item.get('call_id', ''), + 'tool_call_id': output_item.get('call_id', ''), 'content': content, } ) + pending_tool_outputs = [] + + for item in output: + item_type = item.get('type', '') + if item_type not in {'function_call', 'function_call_output'}: + flush_tool_outputs() + flush_tool_images() + + if item_type == 'message': + # Extract text from output_text content parts + content_parts = item.get('content', []) + text = '' + for part in content_parts: + if part.get('type') == 'output_text': + text += part.get('text', '') + if text: + pending_content.append(text) + + elif item_type == 'function_call': + if item.get('call_id') not in function_call_ids: + continue + + # Collect tool calls to batch into assistant message + arguments = item.get('arguments', '{}') + # Ensure arguments is always a JSON string + if not isinstance(arguments, str): + arguments = JSONCodec.dumps(arguments) + pending_tool_calls.append( + { + 'id': item.get('call_id', ''), + 'type': 'function', + 'function': { + 'name': item.get('name', ''), + 'arguments': arguments, + }, + } + ) + + elif item_type == 'function_call_output': + if item.get('call_id') not in function_call_ids: + continue + + pending_tool_outputs.append(item) + elif item_type == 'reasoning': reasoning_details = item.get('reasoning_details') if raw else None + if reasoning_details: + reasoning_details = reasoning_details if isinstance(reasoning_details, list) else [reasoning_details] + reasoning_details = [ + detail + for detail in reasoning_details + if isinstance(detail, dict) + and (detail.get('format') != 'anthropic-claude-v1' or detail.get('signature')) + ] if not reasoning_format and not reasoning_details: continue @@ -429,18 +532,16 @@ def convert_output_to_messages( if reasoning_text: if reasoning_format == 'think_tags': - # Ollama: embed in content with the item's original tags + # Legacy tag replay: embed in content with the item's original tags. start_tag = item.get('start_tag', '') end_tag = item.get('end_tag', '') pending_content.append(f'{start_tag}{reasoning_text}{end_tag}') - elif reasoning_format == 'reasoning_content': - # llama.cpp: collect for reasoning_content field + elif reasoning_format in {'thinking', 'reasoning_content'}: + # Native providers: collect for their top-level reasoning field. pending_reasoning.append(reasoning_text) if reasoning_details: - pending_reasoning_details.extend( - reasoning_details if isinstance(reasoning_details, list) else [reasoning_details] - ) + pending_reasoning_details.extend(reasoning_details) elif item_type == 'open_webui:code_interpreter': # Always include code interpreter content so the LLM knows @@ -466,6 +567,7 @@ def convert_output_to_messages( pass # Flush remaining content/tool_calls + flush_tool_outputs() flush_tool_images() flush_pending() @@ -784,6 +886,17 @@ def sanitize_filename(file_name): return final_file_name +def json_text_variants(value: str) -> list[str]: + """Both spellings ``value`` can take inside a serialized JSON column, unquoted. + + Encoders disagree on non-ASCII — stdlib escapes it to ``\\uXXXX``, orjson writes it + raw — so a LIKE against the stored text has to accept either. ASCII collapses to one. + """ + raw = JSONCodec.dumps(value, ensure_ascii=False)[1:-1] + escaped = JSONCodec.dumps(value, ensure_ascii=True)[1:-1] + return [raw] if raw == escaped else [raw, escaped] + + def sanitize_text_for_db(text: str) -> str: """Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage.""" if not isinstance(text, str): @@ -822,7 +935,7 @@ def sanitize_data_for_db(obj): # json.dumps is implemented in C and much faster than a Python-level # recursive walk over every leaf string. try: - serialized = json.dumps(obj, ensure_ascii=False) + serialized = JSONCodec.dumps(obj, ensure_ascii=False) if '\\u0000' not in serialized: serialized.encode('utf-8') return obj @@ -854,7 +967,7 @@ def sanitize_metadata(metadata: dict) -> dict: return None # Last resort: try to see if it's serializable try: - json.dumps(obj) + JSONCodec.dumps(obj) return obj except (TypeError, ValueError): return None @@ -864,7 +977,7 @@ def sanitize_metadata(metadata: dict) -> dict: if isinstance(obj, (str, int, float, bool, type(None), dict, list)): return True try: - json.dumps(obj) + JSONCodec.dumps(obj) return True except (TypeError, ValueError): return False @@ -1018,7 +1131,7 @@ def convert_logit_bias_input_to_json(logit_bias_input) -> str | None: return None if isinstance(logit_bias_input, dict): - return json.dumps(logit_bias_input) + return JSONCodec.dumps(logit_bias_input) logit_bias_pairs = logit_bias_input.split(',') logit_bias_json = {} @@ -1028,7 +1141,7 @@ def convert_logit_bias_input_to_json(logit_bias_input) -> str | None: bias = int(bias.strip()) bias = 100 if bias > 100 else -100 if bias < -100 else bias logit_bias_json[token] = bias - return json.dumps(logit_bias_json) + return JSONCodec.dumps(logit_bias_json) def freeze(value): @@ -1049,16 +1162,17 @@ def throttle(interval: float = 10.0): different types, the return type of the function should be T | None. :param interval: Duration in seconds to wait before allowing the function to be called again. + Zero or negative disables throttling. """ def decorator(func): + if interval <= 0: + return func + last_calls = {} lock = threading.Lock() async def wrapper(*args, **kwargs): - if interval is None: - return await func(*args, **kwargs) - key = (args, freeze(kwargs)) now = time.time() if now - last_calls.get(key, 0) < interval: @@ -1151,64 +1265,48 @@ async def stream_wrapper(response, session, content_handler=None): def stream_chunks_handler(stream: aiohttp.StreamReader): """ - Handle stream response chunks, supporting large data chunks that exceed the original 16kb limit. - When a single line exceeds max_buffer_size, returns an empty JSON string {} and skips subsequent data - until encountering normally sized data. + Assemble lines from raw chunks, so a line over aiohttp's reader limit no longer aborts the stream. + When CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE is set, a line exceeding it is dropped. :param stream: The stream reader to handle. - :return: An async generator that yields the stream data. + :return: An async generator that yields the stream one line at a time. """ max_buffer_size = CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE if max_buffer_size is None or max_buffer_size <= 0: - return stream + max_buffer_size = float('inf') # unset: no line is too long async def yield_safe_stream_chunks(): - buffer = b'' - skip_mode = False + buffer = bytearray() # bytearray, not bytes: `+=` on bytes reallocates, quadratic on long lines + dropping_line_tail = False async for data, _ in stream.iter_chunks(): if not data: continue - # In skip_mode, if buffer already exceeds the limit, clear it (it's part of an oversized line) - if skip_mode and len(buffer) > max_buffer_size: - buffer = b'' + buffer += data - lines = (buffer + data).split(b'\n') + # Only split once a line completed: splitting every chunk re-copies the buffer, quadratic + if b'\n' in data: + *lines, rest = bytes(buffer).split(b'\n') + buffer = bytearray(rest) - # Process complete lines (except the last possibly incomplete fragment) - for i in range(len(lines) - 1): - line = lines[i] - - if skip_mode: - # Skip mode: check if current line is small enough to exit skip mode - if len(line) <= max_buffer_size: - skip_mode = False - yield line - else: - yield b'data: {}\n' - else: - # Normal mode: check if line exceeds limit - if len(line) > max_buffer_size: - skip_mode = True - yield b'data: {}\n' - log.info(f'Skip mode triggered, line size: {len(line)}') + for line in lines: + if dropping_line_tail: + dropping_line_tail = False + elif len(line) > max_buffer_size: + log.info('Dropped line over max buffer size: %s bytes', len(line)) else: yield line + b'\n' - # Save the last incomplete fragment - buffer = lines[-1] + # Oversized line still arriving: drop it instead of buffering the rest + if len(buffer) > max_buffer_size: + if not dropping_line_tail: + log.info('Dropping line over max buffer size, buffered so far: %s bytes', len(buffer)) + dropping_line_tail = True + buffer.clear() - # Check if buffer exceeds limit - if not skip_mode and len(buffer) > max_buffer_size: - skip_mode = True - log.info(f'Skip mode triggered, buffer size: {len(buffer)}') - # Clear oversized buffer to prevent unlimited growth - buffer = b'' - - # Process remaining buffer data - if buffer and not skip_mode: - yield buffer + b'\n' + if buffer and not dropping_line_tail: + yield bytes(buffer) return yield_safe_stream_chunks() diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 79ea1030a2..1c648b7f38 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -71,6 +71,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) 'evaluation.arena.models', 'models.default_metadata', ) + if refresh: + await openai.get_all_models.cache.clear() + await ollama.get_all_models.cache.clear() + if ( request.app.state.MODELS and request.app.state.BASE_MODELS @@ -182,7 +186,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) model['action_ids'] = action_ids model['filter_ids'] = filter_ids else: - models.remove(model) + models = [m for m in models if m is not model] elif custom_model.is_active: if custom_model.id in existing_ids: @@ -307,7 +311,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) try: await get_function_module_from_cache(request, function_id, function=function) except Exception as e: - log.debug(f'Failed to load function module for {function_id}: {e}') + log.debug('Failed to load function module for %s: %s', function_id, e) # Apply global model defaults to all models # Per-model overrides take precedence over global defaults @@ -377,13 +381,13 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if items is None: action_function = functions_by_id.get(action_id) if action_function is None: - log.info(f'Action not found: {action_id}') + log.info('Action not found: %s', action_id) action_items_by_id[action_id] = [] continue function_module = functions_cache.get(action_id) if function_module is None: - log.info(f'Failed to load action module: {action_id}') + log.info('Failed to load action module: %s', action_id) action_items_by_id[action_id] = [] continue items = get_action_items_from_module(action_function, function_module) @@ -397,13 +401,13 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if items is None: filter_function = functions_by_id.get(filter_id) if filter_function is None: - log.info(f'Filter not found: {filter_id}') + log.info('Filter not found: %s', filter_id) filter_items_by_id[filter_id] = [] continue function_module = functions_cache.get(filter_id) if function_module is None: - log.info(f'Failed to load filter module: {filter_id}') + log.info('Failed to load filter module: %s', filter_id) filter_items_by_id[filter_id] = [] continue if getattr(function_module, 'toggle', None): @@ -413,7 +417,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) filter_items_by_id[filter_id] = items model['filters'].extend({**item} for item in items) - log.debug(f'get_all_models() returned {len(models)} models') + log.debug('get_all_models() returned %s models', len(models)) models_dict = {model['id']: model for model in models} if isinstance(request.app.state.MODELS, RedisDict): diff --git a/backend/open_webui/utils/notifications.py b/backend/open_webui/utils/notifications.py index 0ad94a6377..9e63d02f08 100644 --- a/backend/open_webui/utils/notifications.py +++ b/backend/open_webui/utils/notifications.py @@ -343,6 +343,9 @@ def _notification_webhook_content(event: Any) -> tuple[str, str, dict[str, Any], return str(title), message, event.model_dump(), message if title else None +# LICENSE covers this Open WebUI notification identifier. +# Do not alter, remove, obscure, or replace it except as LICENSE permits: +# https://docs.openwebui.com/license. async def test_target(user_id: str, target_id: str, app_name: str = 'Open WebUI') -> dict[str, Any]: notifications = await _load_notifications(user_id) target = _find_target(notifications, target_id) @@ -351,6 +354,9 @@ async def test_target(user_id: str, target_id: str, app_name: str = 'Open WebUI' await _send_webhook( app_name, target, + # LICENSE covers this Open WebUI notification copy. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'This is a test notification from Open WebUI.', {'action': 'test', 'user_id': user_id}, 'Test notification', @@ -358,8 +364,15 @@ async def test_target(user_id: str, target_id: str, app_name: str = 'Open WebUI' return {'ok': True} +# LICENSE covers this Open WebUI notification identifier. +# Do not alter, remove, obscure, or replace it except as LICENSE permits: +# https://docs.openwebui.com/license. async def notify_target( - user_id: str, message: str, target: str = '', title: str = '', app_name: str = 'Open WebUI' + user_id: str, + message: str, + target: str = '', + title: str = '', + app_name: str = 'Open WebUI', ) -> dict[str, Any]: notifications = await _load_notifications(user_id) item = _find_target(notifications, target) @@ -383,6 +396,9 @@ async def dispatch_notification_event(app: Any, event: Any) -> None: from open_webui.events import event_user_ids + # LICENSE covers this Open WebUI notification identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. app_name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI') for user_id in event_user_ids(event): try: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 0cbfcb2ebf..96505a6be0 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -2,15 +2,14 @@ import asyncio import base64 import fnmatch import hashlib -import json import logging import re import sys -import time import urllib import uuid from dataclasses import dataclass, field from datetime import datetime, timedelta +from functools import partialmethod from types import SimpleNamespace from typing import Literal, Optional @@ -19,14 +18,13 @@ import jwt from authlib.integrations.starlette_client import OAuth from authlib.oauth2.rfc6749.errors import OAuth2Error from authlib.oidc.core import UserInfo -from cryptography.fernet import Fernet +from cryptography.fernet import Fernet, InvalidToken from fastapi import ( HTTPException, status, ) from joserfc.errors import BadSignatureError from joserfc.jws import JWSRegistry -from joserfc.registry import HeaderParameter from mcp.shared.auth import ( OAuthClientMetadata as MCPOAuthClientMetadata, ) @@ -35,8 +33,8 @@ from mcp.shared.auth import ( ) from open_webui.config import ( DEFAULT_USER_ROLE, - ENABLE_OAUTH_GROUP_CREATION, ENABLE_OAUTH, + ENABLE_OAUTH_GROUP_CREATION, ENABLE_OAUTH_GROUP_MANAGEMENT, ENABLE_OAUTH_ROLE_MANAGEMENT, ENABLE_OAUTH_SIGNUP, @@ -67,7 +65,6 @@ from open_webui.config import ( WEBHOOK_URL, ) from open_webui.constants import ERROR_MESSAGES -from open_webui.events import EVENTS, publish_event from open_webui.env import ( AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, @@ -75,22 +72,32 @@ from open_webui.env import ( ENABLE_OAUTH_ID_TOKEN_COOKIE, OAUTH_CLIENT_INFO_ENCRYPTION_KEY, OAUTH_MAX_SESSIONS_PER_USER, - REDIS_KEY_PREFIX, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, ) +from open_webui.events import EVENTS, publish_event from open_webui.models.auths import Auths from open_webui.models.config import Config from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import Users from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url -from open_webui.utils.auth import create_token, get_password_hash +from open_webui.utils.auth import ( + create_token, + get_password_hash, + get_optional_verified_user_from_request, + get_verified_user_by_id, + revoke_user_tokens, +) from open_webui.utils.groups import apply_default_group_assignment from open_webui.utils.misc import parse_duration from open_webui.utils.validate import validate_profile_image_url from starlette.responses import RedirectResponse +# Some IdPs put private params in ID token JOSE headers (CAS: client_id, CyberArk: app_id). +# Authlib exposes no way to pass a registry, so relax it globally; crit, alg and signature checks still apply. +JWSRegistry.__init__ = partialmethod(JWSRegistry.__init__, strict_check_header=False) + class OAuthClientMetadata(MCPOAuthClientMetadata): token_endpoint_auth_method: Literal['none', 'client_secret_basic', 'client_secret_post'] = 'client_secret_post' @@ -114,6 +121,7 @@ class OAuthClientInformationFull(OAuthClientMetadata): from open_webui.env import GLOBAL_LOG_LEVEL +from open_webui.utils.json_codec import JSONCodec logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -193,14 +201,6 @@ DEFAULT_TOKEN_EXPIRY_SECONDS = 3600 NON_EXPIRING_TOKEN_EXPIRES_AT = 253402300799 # 9999-12-31 23:59:59 UTC -# Apereo CAS includes client_id in ID token JWS headers; Authlib 1.7/joserfc -# rejects unknown headers unless we register the provider extension. -JWSRegistry.default_header_registry.setdefault( - 'client_id', - HeaderParameter('OAuth client identifier', 'str'), -) - - def _normalize_token_expiry(token: dict) -> dict: """Ensure a token dict always has a numeric ``expires_at``. @@ -241,7 +241,7 @@ def _normalize_token_expiry(token: dict) -> dict: if exp is not None: expires_at = min(expires_at, int(exp)) except Exception as e: - log.debug(f'Could not read exp from id_token: {e}') + log.debug('Could not read exp from id_token: %s', e) token['expires_at'] = expires_at return token @@ -265,7 +265,7 @@ except Exception as e: def encrypt_data(data) -> str: """Encrypt data for storage""" try: - data_json = json.dumps(data) + data_json = JSONCodec.dumps(data) encrypted = FERNET.encrypt(data_json.encode()).decode() return encrypted except Exception as e: @@ -275,12 +275,8 @@ def encrypt_data(data) -> str: def decrypt_data(data: str): """Decrypt data from storage""" - try: - decrypted = FERNET.decrypt(data.encode()).decode() - return json.loads(decrypted) - except Exception as e: - log.error(f'Error decrypting data: {e}') - raise + decrypted = FERNET.decrypt(data.encode()).decode() + return JSONCodec.loads(decrypted) def _build_oauth_callback_error_message(e: Exception) -> str: @@ -411,7 +407,7 @@ async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceM ) if match: resource_metadata_urls = [match.group(1) or match.group(2)] - log.debug(f'Found resource_metadata URL: {resource_metadata_urls[0]}') + log.debug('Found resource_metadata URL: %s', resource_metadata_urls[0]) else: # Fall back to well-known resource metadata URIs (RFC 9728 §4.2) parsed, base_url = get_parsed_and_base_url(server_url) @@ -423,7 +419,7 @@ async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceM resource_metadata_urls.append( urllib.parse.urljoin(base_url, '/.well-known/oauth-protected-resource') ) - log.debug(f'No resource_metadata in header, trying well-known URIs: {resource_metadata_urls}') + log.debug('No resource_metadata in header, trying well-known URIs: %s', resource_metadata_urls) # Fetch Protected Resource metadata from candidate URLs for resource_metadata_url in resource_metadata_urls: @@ -436,22 +432,22 @@ async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceM resource = resource_metadata.get('resource') or None if resource: - log.debug(f'Discovered resource indicator: {resource}') + log.debug('Discovered resource indicator: %s', resource) servers = resource_metadata.get('authorization_servers', []) scopes = resource_metadata.get('scopes_supported', []) if scopes: - log.debug(f'Discovered resource scopes: {scopes}') + log.debug('Discovered resource scopes: %s', scopes) if servers: authorization_servers = servers - log.debug(f'Discovered authorization servers: {servers}') + log.debug('Discovered authorization servers: %s', servers) break except Exception as e: - log.debug(f'Failed to fetch resource metadata from {resource_metadata_url}: {e}') + log.debug('Failed to fetch resource metadata from %s: %s', resource_metadata_url, e) continue except Exception as e: - log.debug(f'MCP Protected Resource discovery failed: {e}') + log.debug('MCP Protected Resource discovery failed: %s', e) return ProtectedResourceMetadata( resource=resource, authorization_servers=authorization_servers, scopes_supported=scopes @@ -506,6 +502,9 @@ async def get_oauth_client_info_with_dynamic_client_registration( redirect_base_url = (str(webui_url or request.base_url)).rstrip('/') oauth_client_metadata = OAuthClientMetadata( + # LICENSE covers this Open WebUI OAuth client identifier. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. client_name='Open WebUI', redirect_uris=[f'{redirect_base_url}/oauth/clients/{client_id}/callback'], grant_types=['authorization_code', 'refresh_token'], @@ -605,7 +604,9 @@ async def get_oauth_client_info_with_dynamic_client_registration( } ) log.info( - f'Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}' + 'Dynamic client registration successful at %s, client_id: %s', + registration_url, + oauth_client_info.client_id, ) return oauth_client_info except Exception as e: @@ -698,7 +699,7 @@ async def get_oauth_client_info_with_static_credentials( ) log.info( - f'Static OAuth client info built for {oauth_client_id} using metadata from {oauth_server_metadata_url}' + 'Static OAuth client info built for %s using metadata from %s', oauth_client_id, oauth_server_metadata_url ) return oauth_client_info except Exception as e: @@ -798,13 +799,13 @@ async def recover_static_oauth_client_metadata(connection: dict, oauth_client_in try: resource_metadata = await get_protected_resource_metadata(server_url) except Exception as e: - log.debug(f'Unable to recover static OAuth metadata for {server_url}: {e}') + log.debug('Unable to recover static OAuth metadata for %s: %s', server_url, e) return oauth_client_info recovered = {**oauth_client_info} if not recovered.get('scope') and resource_metadata.scopes_supported: recovered['scope'] = ' '.join(resource_metadata.scopes_supported) - log.info(f'Recovered static OAuth scopes for {server_url} from protected resource metadata') + log.info('Recovered static OAuth scopes for %s from protected resource metadata', server_url) if not recovered.get('resource') and resource_metadata.resource: recovered['resource'] = resource_metadata.resource @@ -904,8 +905,19 @@ class OAuthClientManager: oauth_client_info = await recover_static_oauth_client_metadata(connection, oauth_client_info) oauth_client_info = apply_connection_oauth_options(connection, oauth_client_info) return self.add_client(expected_client_id, OAuthClientInformationFull(**oauth_client_info))['client'] + except InvalidToken: + log.error( + 'Failed to lazily add OAuth client %s from config: InvalidToken. ' + 'Stored OAuth client data is invalid; reconnect this tool server.', + expected_client_id, + ) + continue except Exception as e: - log.error(f'Failed to lazily add OAuth client {expected_client_id} from config: {e}') + log.error( + 'Failed to lazily add OAuth client %s from config: %s', + expected_client_id, + f'{type(e).__name__}: {e}' if str(e) else type(e).__name__, + ) continue return None @@ -913,7 +925,7 @@ class OAuthClientManager: def remove_client(self, client_id): if client_id in self.clients: del self.clients[client_id] - log.info(f'Removed OAuth client {client_id}') + log.info('Removed OAuth client %s', client_id) if hasattr(self.oauth, '_clients'): if client_id in self.oauth._clients: @@ -943,9 +955,7 @@ class OAuthClientManager: if not authorization_url: return True except Exception as e: - log.debug( - f'Skipping OAuth preflight for client {client_info.client_id}: {e}', - ) + log.debug('Skipping OAuth preflight for client %s: %s', client_info.client_id, e) return True try: @@ -965,7 +975,7 @@ class OAuthClientManager: content_type = resp.headers.get('content-type', '') if 'application/json' in content_type: try: - payload = json.loads(response_text) + payload = JSONCodec.loads(response_text) error = payload.get('error') error_description = payload.get('error_description', '') except Exception: @@ -991,7 +1001,7 @@ class OAuthClientManager: return False except Exception as e: - log.debug(f'Skipping OAuth preflight network check for client {client_info.client_id}: {e}') + log.debug('Skipping OAuth preflight network check for client %s: %s', client_info.client_id, e) return True @@ -1040,7 +1050,7 @@ class OAuthClientManager: or session.expires_at is None or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at) ): - log.debug(f'Token refresh needed for user {user_id}, client_id {session.provider}') + log.debug('Token refresh needed for user %s, client_id %s', user_id, session.provider) refreshed_token = await self._refresh_token(session) if refreshed_token: return refreshed_token @@ -1073,7 +1083,7 @@ class OAuthClientManager: if refreshed_token: # Update the session with new token data session = await OAuthSessions.update_session_by_id(session.id, refreshed_token) - log.info(f'Successfully refreshed token for session {session.id}') + log.info('Successfully refreshed token for session %s', session.id) return session.token else: log.error(f'Failed to refresh token for session {session.id}') @@ -1157,7 +1167,7 @@ class OAuthClientManager: _normalize_token_expiry(new_token_data) - log.debug(f'Token refresh successful for client_id {client_id}') + log.debug('Token refresh successful for client_id %s', client_id) return new_token_data else: error_text = await r.text() @@ -1168,7 +1178,7 @@ class OAuthClientManager: log.error(f'Exception during token refresh for client_id {client_id}: {e}') return None - async def handle_authorize(self, request, client_id: str) -> RedirectResponse: + async def handle_authorize(self, request, client_id: str, user_id: str) -> RedirectResponse: client = await self.get_client(client_id) if client is None: raise HTTPException(404) @@ -1184,7 +1194,15 @@ class OAuthClientManager: # Pass explicit scope/resource parameters for providers that require them. kwargs = build_oauth_request_params(client_info) try: - return await client.authorize_redirect(request, redirect_uri_str, **kwargs) + auth_data = await client.create_authorization_url(redirect_uri_str, **kwargs) + if not auth_data.get('state'): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='OAuth authorization state was not generated', + ) + auth_data['user_id'] = user_id + await client.save_authorize_data(request, redirect_uri=redirect_uri_str, **auth_data) + return RedirectResponse(auth_data['url'], status_code=302) except RuntimeError as e: # authlib raises RuntimeError('Missing "authorize_url" value') when the # authorization endpoint could not be resolved from server metadata. @@ -1200,14 +1218,36 @@ class OAuthClientManager: ), ) - async def handle_callback(self, request, client_id: str, user_id: str, response): + async def handle_callback(self, request, client_id: str, response): client = await self.get_client(client_id) if client is None: raise HTTPException(404) error_message = None + state = request.query_params.get('state') + user_id = None try: client_info = await self.get_client_info(client_id) + state_data = await client.framework.get_state_data(request.session, state) if state else None + user_id = state_data.get('user_id') if state_data else None + if not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='OAuth callback state is invalid or expired', + ) + + if not await get_verified_user_by_id(user_id): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='OAuth callback user is not authorized', + ) + + request_user = await get_optional_verified_user_from_request(request) + if request_user and request_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='OAuth callback user does not match authenticated session', + ) # Note: Do NOT pass client_id/client_secret explicitly here. # The Authlib client already has these configured during add_client(). @@ -1241,7 +1281,7 @@ class OAuthClientManager: provider=client_id, token=token, ) - log.info(f'Stored OAuth session server-side for user {user_id}, client_id {client_id}') + log.info('Stored OAuth session server-side for user %s, client_id %s', user_id, client_id) except Exception as e: error_message = 'Failed to store OAuth session server-side' log.error(f'Failed to store OAuth session server-side: {e}') @@ -1258,6 +1298,9 @@ class OAuthClientManager: error_message, exc_info=True, ) + finally: + if state and client is not None: + await client.framework.clear_state_data(request.session, state) webui_url = await Config.get('webui.url') redirect_url = (str(webui_url or request.base_url)).rstrip('/') @@ -1323,8 +1366,9 @@ class OAuthManager: # the session (#24618). if (session.provider or '').startswith('mcp:'): log.debug( - f'Skipping MCP session {session.id} (provider={session.provider}) ' - f'in SSO OAuthManager — handled by oauth_client_manager' + 'Skipping MCP session %s (provider=%s) in SSO OAuthManager — handled by oauth_client_manager', + session.id, + session.provider, ) return None @@ -1333,7 +1377,7 @@ class OAuthManager: or session.expires_at is None or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at) ): - log.debug(f'Token refresh needed for user {user_id}, provider {session.provider}') + log.debug('Token refresh needed for user %s, provider %s', user_id, session.provider) refreshed_token = await self._refresh_token(session) if refreshed_token: return refreshed_token @@ -1367,7 +1411,7 @@ class OAuthManager: if refreshed_token: # Update the session with new token data session = await OAuthSessions.update_session_by_id(session.id, refreshed_token) - log.info(f'Successfully refreshed token for session {session.id}') + log.info('Successfully refreshed token for session %s', session.id) return session.token else: log.error(f'Failed to refresh token for session {session.id}') @@ -1449,7 +1493,7 @@ class OAuthManager: _normalize_token_expiry(new_token_data) - log.debug(f'Token refresh successful for provider {provider}') + log.debug('Token refresh successful for provider %s', provider) return new_token_data else: error_text = await r.text() @@ -1481,8 +1525,8 @@ class OAuthManager: oauth_allowed_roles = auth_config.OAUTH_ALLOWED_ROLES oauth_admin_roles = auth_config.OAUTH_ADMIN_ROLES oauth_roles = [] - # Default/fallback role if no matching roles are found - role = auth_config.DEFAULT_USER_ROLE + # Keep existing users at their current role unless the provider sent roles. + role = user.role if user else auth_config.DEFAULT_USER_ROLE # Next block extracts the roles from the user data, accepting nested claims of any depth if oauth_claim and oauth_allowed_roles and oauth_admin_roles: @@ -1508,10 +1552,10 @@ class OAuthManager: elif isinstance(claim_data, int): oauth_roles = [str(claim_data)] - log.debug(f'Oauth Roles claim: {oauth_claim}') - log.debug(f'User roles from oauth: {oauth_roles}') - log.debug(f'Accepted user roles: {oauth_allowed_roles}') - log.debug(f'Accepted admin roles: {oauth_admin_roles}') + log.debug('Oauth Roles claim: %s', oauth_claim) + log.debug('User roles from oauth: %s', oauth_roles) + log.debug('Accepted user roles: %s', oauth_allowed_roles) + log.debug('Accepted admin roles: %s', oauth_admin_roles) # If roles are present in the token, they must match; otherwise deny access if oauth_roles: @@ -1547,13 +1591,40 @@ class OAuthManager: return role - async def update_user_groups(self, user, user_data, default_permissions, db=None): + async def update_user_role_from_oauth( + self, + request, + user, + user_data, + provider, + *, + db=None, + ): + determined_role = await self.get_user_role(user, user_data) + if user.role == determined_role: + return user + + updated_user = await Users.update_user_role_by_id(user.id, determined_role, db=db) + user = updated_user or user + user.role = determined_role + await publish_event( + request, + EVENTS.USER_ROLE_UPDATED, + actor=user, + subject_id=user.id, + source='oauth', + data={'role': determined_role, 'provider': provider}, + ) + + return user + + async def update_user_groups(self, request, user, user_data, default_permissions, db=None): auth_config = await get_oauth_runtime_config() log.debug('Running OAUTH Group management') oauth_claim = auth_config.OAUTH_GROUPS_CLAIM try: - blocked_groups = json.loads(auth_config.OAUTH_BLOCKED_GROUPS) + blocked_groups = JSONCodec.loads(auth_config.OAUTH_BLOCKED_GROUPS) except Exception as e: log.exception(f'Error loading OAUTH_BLOCKED_GROUPS: {e}') blocked_groups = [] @@ -1588,11 +1659,11 @@ class OAuthManager: # Determine creator ID: Prefer admin, fallback to current user if no admin exists admin_user = await Users.get_super_admin_user() creator_id = admin_user.id if admin_user else user.id - log.debug(f'Using creator ID {creator_id} for potential group creation.') + log.debug('Using creator ID %s for potential group creation.', creator_id) for group_name in user_oauth_groups: if group_name not in all_group_names: - log.info(f"Group '{group_name}' not found via OAuth claim. Creating group...") + log.info("Group '%s' not found via OAuth claim. Creating group...", group_name) try: new_group_form = GroupForm( name=group_name, @@ -1604,11 +1675,21 @@ class OAuthManager: created_group = await Groups.insert_new_group(creator_id, new_group_form, db=db) if created_group: log.info( - f"Successfully created group '{group_name}' with ID {created_group.id} using creator ID {creator_id}" + "Successfully created group '%s' with ID %s using creator ID %s", + group_name, + created_group.id, + creator_id, ) groups_created = True # Add to local set to prevent duplicate creation attempts in this run all_group_names.add(group_name) + await publish_event( + request, + EVENTS.GROUP_CREATED, + subject_id=created_group.id, + source='oauth', + data={'name': created_group.name}, + ) else: log.error(f"Failed to create group '{group_name}' via OAuth.") except Exception as e: @@ -1619,10 +1700,10 @@ class OAuthManager: all_available_groups = await Groups.get_all_groups(db=db) log.debug('Refreshed list of all available groups after creation.') - log.debug(f'Oauth Groups claim: {oauth_claim}') - log.debug(f'User oauth groups: {user_oauth_groups}') - log.debug(f"User's current groups: {[g.name for g in user_current_groups]}") - log.debug(f'All groups available in OpenWebUI: {[g.name for g in all_available_groups]}') + log.debug('Oauth Groups claim: %s', oauth_claim) + log.debug('User oauth groups: %s', user_oauth_groups) + log.debug("User's current groups: %s", [g.name for g in user_current_groups]) + log.debug('All groups available in OpenWebUI: %s', [g.name for g in all_available_groups]) # Remove groups that user is no longer a part of for group_model in user_current_groups: @@ -1632,8 +1713,16 @@ class OAuthManager: and not is_in_blocked_groups(group_model.name, blocked_groups) ): # Remove group from user - log.debug(f'Removing user from group {group_model.name} as it is no longer in their oauth groups') - await Groups.remove_users_from_group(group_model.id, [user.id], db=db) + log.debug('Removing user from group %s as it is no longer in their oauth groups', group_model.name) + if await Groups.remove_users_from_group(group_model.id, [user.id], db=db): + await publish_event( + request, + EVENTS.GROUP_MEMBER_REMOVED, + actor=user, + subject_id=group_model.id, + source='oauth', + data={'user_ids': [user.id]}, + ) # In case a group is created, but perms are never assigned to the group by hitting "save" group_permissions = group_model.permissions @@ -1660,9 +1749,17 @@ class OAuthManager: and not is_in_blocked_groups(group_model.name, blocked_groups) ): # Add user to group - log.debug(f'Adding user to group {group_model.name} as it was found in their oauth groups') + log.debug('Adding user to group %s as it was found in their oauth groups', group_model.name) - await Groups.add_users_to_group(group_model.id, [user.id], db=db) + if await Groups.add_users_to_group(group_model.id, [user.id], db=db): + await publish_event( + request, + EVENTS.GROUP_MEMBER_ADDED, + actor=user, + subject_id=group_model.id, + source='oauth', + data={'user_ids': [user.id]}, + ) # In case a group is created, but perms are never assigned to the group by hitting "save" group_permissions = group_model.permissions @@ -1832,6 +1929,7 @@ class OAuthManager: if not sub: log.warning(f'OAuth callback failed, sub is missing: {user_data}') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + sub = str(sub) oauth_data = {} oauth_data[provider] = { @@ -1896,24 +1994,35 @@ class OAuthManager: user = await Users.get_user_by_email(email, db=db) if user: # Update the user with the new oauth sub - await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) + user = await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) or user if user: - determined_role = await self.get_user_role(user, user_data) - if user.role != determined_role: - await Users.update_user_role_by_id(user.id, determined_role, db=db) - # Update the user object in memory as well, - # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below - user.role = determined_role + provider_oauth = (user.oauth or {}).get(provider) if isinstance(user.oauth, dict) else None + # Lazy repair for legacy rows that stored numeric provider ids as JSON numbers. + if isinstance(provider_oauth, dict) and provider_oauth.get('sub') != sub: + user = await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) or user + + if user: + user = await self.update_user_role_from_oauth( + request=request, + user=user, + user_data=user_data, + provider=provider, + db=db, + ) + + updated_fields = [] if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN: username_claim = auth_config.OAUTH_USERNAME_CLAIM if username_claim: new_name = user_data.get(username_claim) if new_name and new_name != user.name: - await Users.update_user_by_id(user.id, {'name': new_name}, db=db) - user.name = new_name - log.debug(f'Updated name for user {user.email}') + updated_user = await Users.update_user_by_id(user.id, {'name': new_name}, db=db) + if updated_user: + user = updated_user + updated_fields.append('name') + log.debug('Updated name for user %s', user.email) if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: email_claim = auth_config.OAUTH_EMAIL_CLAIM @@ -1925,10 +2034,10 @@ class OAuthManager: log.error( f'Cannot update email to {new_email} for user {user.id} because it is already taken.' ) - else: - await Auths.update_email_by_id(user.id, new_email.lower(), db=db) - user.email = new_email.lower() - log.debug(f'Updated email for user {user.id}') + elif await Auths.update_email_by_id(user.id, new_email.lower(), db=db): + user = await Users.get_user_by_id(user.id, db=db) or user + updated_fields.append('email') + log.debug('Updated email for user %s', user.id) # Update profile picture if enabled and different from current if auth_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: @@ -1942,8 +2051,23 @@ class OAuthManager: new_picture_url, token.get('access_token') ) if processed_picture_url != user.profile_image_url: - await Users.update_user_profile_image_url_by_id(user.id, processed_picture_url, db=db) - log.debug(f'Updated profile picture for user {user.email}') + updated_user = await Users.update_user_profile_image_url_by_id( + user.id, processed_picture_url, db=db + ) + if updated_user: + user = updated_user + updated_fields.append('profile_image_url') + log.debug('Updated profile picture for user %s', user.email) + + if updated_fields: + await publish_event( + request, + EVENTS.USER_UPDATED, + actor=user, + subject_id=user.id, + source='oauth', + data={'updated_fields': updated_fields, 'provider': provider}, + ) else: # If the user does not exist, check if signups are enabled if auth_config.ENABLE_OAUTH_SIGNUP: @@ -2011,6 +2135,7 @@ class OAuthManager: ) if auth_config.ENABLE_OAUTH_GROUP_MANAGEMENT: await self.update_user_groups( + request=request, user=user, user_data=user_data, default_permissions=await Config.get('user.permissions'), @@ -2050,6 +2175,16 @@ class OAuthManager: **({'max_age': cookie_max_age} if cookie_max_age is not None else {}), ) + await publish_event( + request, + EVENTS.AUTH_LOGIN, + actor=user, + subject_id=user.id, + subject_type='user', + source='oauth', + data={'auth_method': 'oauth', 'provider': provider}, + ) + # Legacy cookies for compatibility with older frontend versions if ENABLE_OAUTH_ID_TOKEN_COOKIE: response.set_cookie( @@ -2094,7 +2229,7 @@ class OAuthManager: **({'max_age': cookie_max_age} if cookie_max_age is not None else {}), ) - log.info(f'Stored OAuth session server-side for user {user.id}, provider {provider}') + log.info('Stored OAuth session server-side for user %s, provider %s', user.id, provider) else: log.warning(f'Failed to create OAuth session for user {user.id}, provider {provider}') except Exception as e: @@ -2109,7 +2244,6 @@ class OAuthManager: sessions via Redis, and deletes their OAuth sessions. Returns a JSONResponse per the OIDC Back-Channel Logout 1.0 spec. """ - import jwt as pyjwt from fastapi.responses import JSONResponse # 1. Extract logout_token from form body @@ -2127,7 +2261,7 @@ class OAuthManager: # 2. Peek at unverified issuer to match against configured providers try: - unverified_claims = pyjwt.decode(logout_token, options={'verify_signature': False}) + unverified_claims = jwt.decode(logout_token, options={'verify_signature': False}) token_issuer = unverified_claims.get('iss') except Exception as e: log.warning(f'Back-channel logout: cannot decode logout_token: {e}') @@ -2144,35 +2278,27 @@ class OAuthManager: # 3. Find the configured provider whose issuer matches the token matched_provider = None - matched_client_id = None + matched_client = None matched_jwks_uri = None - matched_issuer = None for provider_name in OAUTH_PROVIDERS: - server_metadata_url = self.get_server_metadata_url(provider_name) - if not server_metadata_url: + client = self.get_client(provider_name) + if not client: continue try: - async with aiohttp.ClientSession(trust_env=True) as session: - async with session.get(server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: - if r.status != 200: - continue - oidc_config = await r.json() - - provider_issuer = oidc_config.get('issuer') - if provider_issuer and provider_issuer == token_issuer: - client = self.get_client(provider_name) - matched_provider = provider_name - matched_client_id = client.client_id if client else None - matched_jwks_uri = oidc_config.get('jwks_uri') - matched_issuer = provider_issuer - break + oidc_config = await client.load_server_metadata() except Exception as e: - log.debug(f'Back-channel logout: error checking provider {provider_name}: {e}') + log.debug('Back-channel logout: error checking provider %s: %s', provider_name, e) continue - if not matched_provider or not matched_client_id or not matched_jwks_uri: + if oidc_config.get('issuer') == token_issuer: + matched_provider = provider_name + matched_client = client + matched_jwks_uri = oidc_config.get('jwks_uri') + break + + if not matched_provider or not matched_client or not matched_client.client_id or not matched_jwks_uri: log.warning(f'Back-channel logout: no configured provider matches issuer {token_issuer}') return JSONResponse( status_code=400, @@ -2184,20 +2310,33 @@ class OAuthManager: # 4. Validate the logout_token signature and claims try: - jwks_client = pyjwt.PyJWKClient(matched_jwks_uri) - signing_key = jwks_client.get_signing_key_from_jwt(logout_token) + token_kid = jwt.get_unverified_header(logout_token).get('kid') + if not token_kid: + raise jwt.InvalidTokenError('logout_token missing kid header') - claims = pyjwt.decode( + try: + jwk_set = jwt.PyJWKSet.from_dict(await matched_client.fetch_jwk_set()) + except jwt.PyJWTError as e: + raise jwt.InvalidTokenError(str(e)) + + signing_key = next( + (key for key in jwk_set.keys if key.key_id == token_kid and key.public_key_use in ['sig', None]), + None, + ) + if not signing_key: + raise jwt.InvalidTokenError('no signing key matches the token kid') + + claims = jwt.decode( logout_token, signing_key.key, algorithms=['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'], - audience=matched_client_id, - issuer=matched_issuer, + audience=matched_client.client_id, + issuer=token_issuer, options={ 'require': ['iss', 'aud', 'iat', 'events'], }, ) - except pyjwt.InvalidTokenError as e: + except jwt.InvalidTokenError as e: log.warning(f'Back-channel logout: invalid logout_token: {e}') return JSONResponse( status_code=400, @@ -2241,15 +2380,17 @@ class OAuthManager: # 8. Identify users to log out users_to_logout = [] if sub: - user = await Users.get_user_by_oauth_sub(matched_provider, sub, db=db) + user = await Users.get_user_by_oauth_sub(matched_provider, str(sub), db=db) if user: users_to_logout.append(user) if not users_to_logout and sid: - log.debug(f'Back-channel logout: no user found by sub, sid-based lookup not yet supported (sid={sid})') + log.debug('Back-channel logout: no user found by sub, sid-based lookup not yet supported (sid=%s)', sid) if not users_to_logout: - log.debug(f'Back-channel logout: no matching user for provider={matched_provider}, sub={sub}, sid={sid}') + log.debug( + 'Back-channel logout: no matching user for provider=%s, sub=%s, sid=%s', matched_provider, sub, sid + ) return JSONResponse(status_code=200, content={}) # 9. Revoke tokens and delete sessions @@ -2267,20 +2408,18 @@ class OAuthManager: await OAuthSessions.delete_session_by_id(oauth_session.id, db=db) if redis: - revocation_key = f'{REDIS_KEY_PREFIX}:auth:user:{user.id}:revoked_at' - await redis.set( - revocation_key, - str(int(time.time())), - ex=60 * 60 * 24 * 30, - ) + await revoke_user_tokens(request, user.id) revoked_count += 1 log.info( - f'Back-channel logout: revoked sessions for user {user.id} ' - f'(email={user.email}, provider={matched_provider}, sessions_deleted={len(sessions)})' + 'Back-channel logout: revoked sessions for user %s (email=%s, provider=%s, sessions_deleted=%s)', + user.id, + user.email, + matched_provider, + len(sessions), ) log.info( - f'Back-channel logout: completed for {len(users_to_logout)} user(s), {revoked_count} revocation(s) set' + 'Back-channel logout: completed for %s user(s), %s revocation(s) set', len(users_to_logout), revoked_count ) return JSONResponse(status_code=200, content={}) diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index e47e72b94f..7ee2f9d3b3 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -1,14 +1,18 @@ -import json +import logging from typing import Callable, Optional +from open_webui.utils.chat_variables import render_chat_variables, render_user_variables +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import ( add_or_update_system_message, + convert_logit_bias_input_to_json, deep_update, replace_system_message_content, ) -from open_webui.utils.chat_variables import render_chat_variables, render_user_variables from open_webui.utils.task import prompt_template, prompt_variables_template +log = logging.getLogger(__name__) + async def resolve_system_prompt( system: Optional[str], @@ -67,7 +71,7 @@ def apply_model_params_to_body(params: dict, form_data: dict, mappings: dict[str return form_data for key, value in params.items(): - if value is not None: + if value is not None and key not in form_data: if key in mappings: cast_func = mappings[key] if isinstance(cast_func, Callable): @@ -78,6 +82,56 @@ def apply_model_params_to_body(params: dict, form_data: dict, mappings: dict[str return form_data +def apply_params_to_form_data(form_data: dict, model: dict, params: dict | None = None) -> dict: + payload_params = form_data.pop('params', {}) or {} + params = payload_params if params is None else dict(params) + custom_params = params.pop('custom_params', {}) + + open_webui_params = { + 'stream_response': bool, + 'stream_delta_chunk_size': int, + 'function_calling': str, + 'reasoning_tags': list, + 'compact_token_threshold': int, + 'system': str, + 'note_id': str, + 'tool_approval_mode': str, + } + + for key in list(params.keys()): + if key in open_webui_params: + del params[key] + + if custom_params: + for key, value in custom_params.items(): + if isinstance(value, str): + try: + custom_params[key] = JSONCodec.loads(value) + except JSONCodec.JSONDecodeError: + pass + + params = deep_update(params, custom_params) + + if model.get('owned_by') == 'ollama': + form_data['options'] = {**params, **(form_data.get('options') or {})} + else: + if isinstance(params, dict): + for key, value in params.items(): + if value is not None and key not in form_data: + form_data[key] = value + + if 'logit_bias' in params and params['logit_bias'] is not None and 'logit_bias' not in form_data: + try: + logit_bias = convert_logit_bias_input_to_json(params['logit_bias']) + + if logit_bias: + form_data['logit_bias'] = JSONCodec.loads(logit_bias) + except Exception as e: + log.exception(f'Error parsing logit_bias: {e}') + + return form_data + + def remove_open_webui_params(params: dict) -> dict: """ Removes OpenWebUI specific parameters from the provided dictionary. @@ -95,6 +149,8 @@ def remove_open_webui_params(params: dict) -> dict: 'reasoning_tags': list, 'compact_token_threshold': int, 'system': str, + 'note_id': str, + 'tool_approval_mode': str, } for key in list(params.keys()): @@ -115,8 +171,8 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: if isinstance(value, str): try: # Attempt to parse the string as JSON - custom_params[key] = json.loads(value) - except json.JSONDecodeError: + custom_params[key] = JSONCodec.loads(value) + except JSONCodec.JSONDecodeError: # If it fails, keep the original string pass @@ -149,8 +205,8 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: if isinstance(value, str): try: # Attempt to parse the string as JSON - custom_params[key] = json.loads(value) - except json.JSONDecodeError: + custom_params[key] = JSONCodec.loads(value) + except JSONCodec.JSONDecodeError: # If it fails, keep the original string pass @@ -198,7 +254,7 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: Parses a JSON string into a dictionary, handling potential JSONDecodeError. """ try: - return json.loads(value) + return JSONCodec.loads(value) except Exception as e: return value @@ -230,6 +286,8 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: # may be injected by filter inlet functions). if 'thinking' in message: new_message['thinking'] = message['thinking'] + elif reasoning_content := (message.get('reasoning_content') or message.get('reasoning')): + new_message['thinking'] = reasoning_content content = message.get('content', []) tool_calls = message.get('tool_calls', None) @@ -253,7 +311,7 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: 'id': tool_call.get('id', None), 'function': { 'name': tool_call.get('function', {}).get('name', ''), - 'arguments': json.loads(tool_call.get('function', {}).get('arguments', {})), + 'arguments': JSONCodec.loads(tool_call.get('function', {}).get('arguments', {})), }, } ollama_tool_calls.append(ollama_tool_call) @@ -336,7 +394,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: Parses a JSON string into a dictionary, handling potential JSONDecodeError. """ try: - return json.loads(value) + return JSONCodec.loads(value) except Exception as e: return value diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index d6d5ff3388..1c6be3858f 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -241,7 +241,7 @@ async def load_tool_module_by_id(tool_id, content=None): exec(content, module.__dict__) if frontmatter is None: frontmatter = extract_frontmatter(content) - log.info(f'Loaded module: {module.__name__}') + log.info('Loaded module: %s', module.__name__) # Create and return the object if the class 'Tools' is found in the module if hasattr(module, 'Tools'): @@ -291,7 +291,7 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non exec(content, module.__dict__) if frontmatter is None: frontmatter = extract_frontmatter(content) - log.info(f'Loaded module: {module.__name__}') + log.info('Loaded module: %s', module.__name__) # Create appropriate object based on available class type in the module if hasattr(module, 'Pipe'): @@ -437,7 +437,7 @@ def install_frontmatter_requirements(requirements: str): if not new_reqs: return - log.info(f'Installing requirements: {" ".join(new_reqs)}') + log.info('Installing requirements: %s', ' '.join(new_reqs)) subprocess.check_call( [sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + new_reqs + PIP_PACKAGE_INDEX_OPTIONS ) diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index 29c021487b..3f34986903 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -1,4 +1,3 @@ -import json from numbers import Number from uuid import uuid4 @@ -25,13 +24,9 @@ def normalize_usage(usage: dict) -> dict: return {} # Map various field names to standard names - input_tokens = ( - usage.get('input_tokens') # Already standard - or usage.get('prompt_tokens') # OpenAI - or usage.get('prompt_eval_count') # Ollama - or usage.get('prompt_n') # llama.cpp - or 0 - ) + input_tokens = usage.get('input_tokens') or usage.get('prompt_tokens') or usage.get('prompt_eval_count') + if input_tokens is None: + input_tokens = int(usage.get('prompt_n') or 0) + int(usage.get('cache_n') or 0) output_tokens = ( usage.get('output_tokens') # Already standard @@ -158,7 +153,7 @@ def convert_ollama_tool_call_to_openai(tool_calls: list) -> list: 'type': 'function', 'function': { 'name': function.get('name', ''), - 'arguments': json.dumps(function.get('arguments', {})), + 'arguments': JSONCodec.dumps(function.get('arguments', {})), }, } openai_tool_calls.append(openai_tool_call) diff --git a/backend/open_webui/utils/security_headers.py b/backend/open_webui/utils/security_headers.py index c6cbbce2ba..c0d043c387 100644 --- a/backend/open_webui/utils/security_headers.py +++ b/backend/open_webui/utils/security_headers.py @@ -2,37 +2,6 @@ import os import re from typing import Dict -from starlette.datastructures import MutableHeaders -from starlette.types import ASGIApp, Message, Receive, Scope, Send - - -class SecurityHeadersMiddleware: - """Apply configured security headers to every HTTP response. - - Pure ASGI to avoid BaseHTTPMiddleware's response re-buffering. See - open_webui.utils.asgi_middleware for the rationale. - """ - - def __init__(self, app: ASGIApp) -> None: - self.app = app - # Headers derive only from env vars, which are static for the process - # lifetime — compute them once instead of per response. - self._headers = list(set_security_headers().items()) - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope['type'] != 'http' or not self._headers: - await self.app(scope, receive, send) - return - - async def send_with_security_headers(message: Message) -> None: - if message['type'] == 'http.response.start': - headers = MutableHeaders(scope=message) - for key, value in self._headers: - headers[key] = value - await send(message) - - await self.app(scope, receive, send_with_security_headers) - def set_security_headers() -> Dict[str, str]: """ diff --git a/backend/open_webui/utils/session_pool.py b/backend/open_webui/utils/session_pool.py index fb66c3e2cf..bc9a3a8f74 100644 --- a/backend/open_webui/utils/session_pool.py +++ b/backend/open_webui/utils/session_pool.py @@ -34,6 +34,7 @@ from open_webui.env import ( AIOHTTP_POOL_CONNECTIONS_PER_HOST, AIOHTTP_POOL_DNS_TTL, ) +from open_webui.utils.misc import stream_chunks_handler log = logging.getLogger(__name__) @@ -115,7 +116,7 @@ async def cleanup_response( await result -async def stream_wrapper(response, session=None, content_handler=None, passthrough=False): +async def stream_wrapper(response, session=None, passthrough=False): """Wrap a stream to ensure cleanup happens even if streaming is interrupted. This is more reliable than BackgroundTask which may not run if the client @@ -126,12 +127,10 @@ async def stream_wrapper(response, session=None, content_handler=None, passthrou line. Only for streams no internal consumer parses line-by-line. """ try: - if content_handler: - stream = content_handler(response.content) - elif passthrough: + if passthrough: stream = response.content.iter_any() else: - stream = response.content + stream = stream_chunks_handler(response.content) async for chunk in stream: yield chunk finally: diff --git a/backend/open_webui/utils/subagents.py b/backend/open_webui/utils/subagents.py index e680068458..809dd601ae 100644 --- a/backend/open_webui/utils/subagents.py +++ b/backend/open_webui/utils/subagents.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import copy -import json import time from datetime import timedelta from uuid import uuid4 @@ -16,6 +15,7 @@ from open_webui.models.config import Config from open_webui.models.users import UserModel, Users from open_webui.tasks import create_task, has_active_tasks from open_webui.utils.auth import create_token +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import get_message_list from sqlalchemy import select from starlette.datastructures import Headers @@ -272,6 +272,7 @@ async def delegate( context: str, background: bool, *, + file_ids: list[str] | None = None, request: Request, user_data: dict, metadata: dict, @@ -331,6 +332,30 @@ async def delegate( return 'Error: model context is required.' if run.get('direct'): return 'Error: sub-agents are unavailable for direct connections.' + if file_ids: + requested_file_ids = {str(file_id) for file_id in file_ids if file_id} + run['files'] = [ + copy.deepcopy(file) + for file in metadata.get('files') or [] + if str(file.get('id') or '') in requested_file_ids + or str(file.get('url') or '') in requested_file_ids + or (isinstance(file.get('file'), dict) and str(file.get('file', {}).get('id') or '') in requested_file_ids) + ] + found_file_ids = { + str(value) + for file in run['files'] + for value in ( + file.get('id'), + file.get('url'), + file.get('file', {}).get('id') if isinstance(file.get('file'), dict) else None, + ) + if value + } + missing_file_ids = sorted(requested_file_ids - found_file_ids) + if missing_file_ids: + return f'Error: file_ids not attached or unavailable: {", ".join(missing_file_ids)}' + else: + run['files'] = [] delegation_id = f'deleg_{uuid4().hex[:8]}' foreground_semaphore = None @@ -355,6 +380,17 @@ async def delegate( user_message_id = str(uuid4()) assistant_message_id = str(uuid4()) prompt = f'{task}\n\n## Context\n{context}' if context else task + prompt_files = copy.deepcopy(run.get('files') or []) + user_message = { + 'id': user_message_id, + 'parentId': None, + 'childrenIds': [assistant_message_id], + 'role': 'user', + 'content': prompt, + 'timestamp': int(time.time()), + 'models': [run['model_id']], + **({'files': prompt_files} if prompt_files else {}), + } chat = await Chats.insert_new_chat( chat_id, user.id, @@ -366,15 +402,7 @@ async def delegate( 'history': { 'currentId': assistant_message_id, 'messages': { - user_message_id: { - 'id': user_message_id, - 'parentId': None, - 'childrenIds': [assistant_message_id], - 'role': 'user', - 'content': prompt, - 'timestamp': int(time.time()), - 'models': [run['model_id']], - }, + user_message_id: user_message, assistant_message_id: { 'id': assistant_message_id, 'parentId': user_message_id, @@ -387,7 +415,14 @@ async def delegate( }, }, }, - 'messages': [{'role': 'user', 'content': prompt}], + 'messages': [ + { + 'role': 'user', + 'content': prompt, + **({'files': prompt_files} if prompt_files else {}), + } + ], + 'files': prompt_files, } ), internal_meta={ @@ -435,12 +470,7 @@ async def delegate( 'chat_id': chat_id, 'id': assistant_message_id, 'parent_id': None, - 'user_message': { - 'id': user_message_id, - 'parentId': None, - 'role': 'user', - 'content': prompt, - }, + 'user_message': user_message, 'session_id': run.get('session_id') or f'subagent:{chat_id}', 'background_tasks': {}, 'tool_ids': run.get('tool_ids') or [], @@ -647,7 +677,7 @@ async def delegate( return f'Error: {exc}' if background: - return json.dumps( + return JSONCodec.dumps( { 'status': 'dispatched', 'delegation_id': delegation_id, diff --git a/backend/open_webui/utils/terminals.py b/backend/open_webui/utils/terminals.py index e97348a8d5..506c15bff9 100644 --- a/backend/open_webui/utils/terminals.py +++ b/backend/open_webui/utils/terminals.py @@ -2,6 +2,19 @@ from urllib.parse import quote +from open_webui.utils.chat_id import is_saved_chat_id + +TERMINAL_CONTEXT_HEADER = 'X-Terminal-Context-Id' +TERMINAL_CONTEXT_DEFAULT = 'default' +TERMINAL_CONTEXT_TYPES = {'chat', 'automation'} +TERMINAL_CONTEXT_ID_SOURCES = {'chat': 'chat_id', 'automation': 'automation_id'} +TERMINAL_CHAT_UPLOAD_MODES = {'default', 'filesystem'} + + +def is_terminal_orchestrator(connection: dict) -> bool: + """Return whether this connection points at Terminals, not raw Open Terminal.""" + return connection.get('server_type') == 'orchestrator' or bool(connection.get('policy_id')) + def get_terminal_server_url(connection: dict) -> str: """Return the upstream base URL for a terminal connection. @@ -14,3 +27,87 @@ def get_terminal_server_url(connection: dict) -> str: if policy_id: return f'{base_url}/p/{quote(policy_id, safe="")}' return base_url + + +def terminal_context_config(connection: dict, context: str) -> dict | bool: + """Return config for an OpenWebUI terminal context. + + Missing config is legacy behavior: available, shared default terminal. + """ + if not is_terminal_orchestrator(connection): + return {} + + contexts = (connection.get('config') or {}).get('contexts') + if not isinstance(contexts, dict): + return {} + + value = contexts.get(context, {}) + if value is False: + return False + return value if isinstance(value, dict) else {} + + +def terminal_context_available(connection: dict, context: str) -> bool: + """Return whether this terminal is exposed in an OpenWebUI context.""" + if context not in TERMINAL_CONTEXT_TYPES: + return False + return terminal_context_config(connection, context) is not False + + +def terminal_context_id( + connection: dict, + metadata: dict | None = None, + context: str = 'chat', +) -> str | None: + """Return the terminal runtime context for trusted request metadata.""" + if not is_terminal_orchestrator(connection) or not terminal_context_available(connection, context): + return None + + config = terminal_context_config(connection, context) + context_id_source = config.get('context_id') if isinstance(config, dict) else None + if not context_id_source or context_id_source == TERMINAL_CONTEXT_DEFAULT: + return None + + if context_id_source != TERMINAL_CONTEXT_ID_SOURCES.get(context): + return None + + metadata = metadata or {} + + if context == 'automation': + automation_id = metadata.get('automation_id') + return f'automation:{automation_id}' if automation_id else None + + chat_id = metadata.get('chat_id') + if context == 'chat' and chat_id and is_saved_chat_id(chat_id): + return f'chat:{chat_id}' + return None + + +def terminal_contexts(connection: dict) -> dict: + """Return normalized sparse context config for clients.""" + if not is_terminal_orchestrator(connection): + return {} + + contexts = (connection.get('config') or {}).get('contexts') + if not isinstance(contexts, dict): + return {} + + result = {} + for context, value in contexts.items(): + if context not in TERMINAL_CONTEXT_TYPES: + continue + if value is False: + result[context] = False + elif isinstance(value, dict): + context_id_source = value.get('context_id') + if context_id_source in {TERMINAL_CONTEXT_DEFAULT, TERMINAL_CONTEXT_ID_SOURCES[context]}: + result[context] = {'context_id': context_id_source} + else: + result[context] = {} + return result + + +def terminal_chat_uploads(connection: dict) -> str: + """Return normalized main-chat upload behavior for this connection.""" + value = (connection.get('config') or {}).get('chat_uploads') + return value if value in TERMINAL_CHAT_UPLOAD_MODES else 'default' diff --git a/backend/open_webui/utils/timers.py b/backend/open_webui/utils/timers.py index 5500daa21a..094a2130c8 100644 --- a/backend/open_webui/utils/timers.py +++ b/backend/open_webui/utils/timers.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import copy -import json import logging import re import time @@ -13,15 +12,15 @@ from typing import Literal from uuid import uuid4 from fastapi import Request -from sqlalchemy import select -from starlette.datastructures import Headers - from open_webui.internal.db import get_async_db from open_webui.models.chat_messages import ChatMessages from open_webui.models.chats import Chat, ChatForm, Chats from open_webui.models.users import UserModel, Users from open_webui.tasks import has_active_tasks +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import get_message_list +from sqlalchemy import select +from starlette.datastructures import Headers log = logging.getLogger(__name__) @@ -153,11 +152,12 @@ async def create_timer( 'cancel_on': selected_events, 'run': run, }, + timer_at=due_at, ) if not chat: return 'Error: failed to create timer.' - return json.dumps( + return JSONCodec.dumps( { 'status': 'set', 'at': datetime.fromtimestamp(due_at / 1_000_000_000, timezone.utc).isoformat().replace('+00:00', 'Z'), @@ -172,20 +172,18 @@ async def claim_due_timers(now_ns: int, limit: int = 10) -> list[tuple[str, str] async with get_async_db() as db: stmt = ( select(Chat) - .where(Chat.meta['internal'].as_boolean().is_(True)) - .where(Chat.meta['type'].as_string() == 'timer') + .where(Chat.timer_at <= now_ns) .where(Chat.meta['status'].as_string() == 'pending') + .order_by(Chat.timer_at) + .limit(limit) ) if db.bind.dialect.name == 'postgresql': stmt = stmt.with_for_update(skip_locked=True) result = await db.execute(stmt) - rows = [row for row in result.scalars().all() if int((row.meta or {}).get('timer_at') or 0) <= now_ns] - rows.sort(key=lambda row: int((row.meta or {}).get('timer_at') or 0)) - rows = rows[:limit] claimed = [] - for row in rows: + for row in result.scalars().all(): claim_id = str(uuid4()) row.meta = { **(row.meta or {}), @@ -193,6 +191,7 @@ async def claim_due_timers(now_ns: int, limit: int = 10) -> list[tuple[str, str] 'timer_started_at': now_ns, 'timer_claim_id': claim_id, } + row.timer_at = None row.updated_at = int(time.time()) claimed.append((row.id, claim_id)) await db.commit() @@ -206,9 +205,8 @@ async def cancel_timers_for_chat( async with get_async_db() as db: result = await db.execute( select(Chat) + .where(Chat.timer_at.isnot(None)) .where(Chat.user_id == user_id) - .where(Chat.meta['internal'].as_boolean().is_(True)) - .where(Chat.meta['type'].as_string() == 'timer') .where(Chat.meta['parent_chat_id'].as_string() == parent_chat_id) .where(Chat.meta['status'].as_string() == 'pending') ) @@ -223,6 +221,7 @@ async def cancel_timers_for_chat( 'timer_cancelled_at': now_ns, 'timer_cancelled_by': event, } + row.timer_at = None row.updated_at = int(time.time()) await db.commit() @@ -290,12 +289,14 @@ async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> if await has_active_tasks(app.state.redis, parent_chat_id): timer_row = await db.get(Chat, timer_id) if timer_row: + timer_meta = timer_row.meta or {} timer_row.meta = { - **(timer_row.meta or {}), + **timer_meta, 'status': 'pending', 'timer_claim_id': None, 'timer_started_at': None, } + timer_row.timer_at = timer_meta.get('timer_at') timer_row.updated_at = int(time.time()) await db.commit() return @@ -405,7 +406,11 @@ async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> ) request.state.token = None request.state.enable_api_keys = False - await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + try: + await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + except Exception as exc: + log.exception(f'Timer {timer_id} completion failed') + await _set_timer_state(timer_id, 'error', timer_error=str(exc)[:500]) async def _set_timer_state(timer_id: str, status: str, **fields) -> None: diff --git a/backend/open_webui/utils/tool_approval.py b/backend/open_webui/utils/tool_approval.py new file mode 100644 index 0000000000..c59b2d6316 --- /dev/null +++ b/backend/open_webui/utils/tool_approval.py @@ -0,0 +1,186 @@ +from typing import Any, Literal + +from fastapi import HTTPException, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from open_webui.constants import ERROR_MESSAGES +from open_webui.models.chats import Chats +from open_webui.socket.main import get_event_emitter +from open_webui.utils.json_codec import JSONCodec + + +class ResolveToolCallForm(BaseModel): + call_id: str + action: Literal['approve', 'reject', 'answer'] + answers: Any | None = None + timed_out: bool = False + + +async def resolve_tool_call_output( + chat_id: str, + message_id: str, + form_data: ResolveToolCallForm, + user, + db: AsyncSession | None = None, +) -> dict: + chat = await Chats.get_chat_by_id(chat_id, db=db) + if not chat or (chat.user_id != user.id and user.role != 'admin'): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) + if not message: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + output = message.get('output') or [] + if not isinstance(output, list): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Message has no resolvable output.') + + function_call = next( + ( + item + for item in output + if item.get('type') == 'function_call' and (item.get('call_id') or item.get('id')) == form_data.call_id + ), + None, + ) + if not function_call: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Tool call not found.') + function_call.setdefault('call_id', form_data.call_id) + tool_name = function_call.get('name') + + if any( + item.get('type') == 'function_call_output' and item.get('call_id') == form_data.call_id for item in output + ) or function_call.get('status') not in {'pending', 'queued', 'requires_approval'}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call has already been resolved.') + + if form_data.action == 'approve': + if tool_name == 'ask_user': + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='ask_user requires an answer or deny.') + function_call['status'] = 'queued' + function_call['approved'] = True + elif form_data.action == 'reject': + function_call['status'] = 'rejected' + output.append( + { + 'type': 'function_call_output', + 'id': f'fco_{form_data.call_id}', + 'call_id': form_data.call_id, + 'output': [{'type': 'input_text', 'text': 'Error: tool call rejected by user.'}], + 'status': 'rejected', + } + ) + else: + if tool_name != 'ask_user': + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Tool call does not accept answers.') + if form_data.answers is None and not form_data.timed_out: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Answers are required for ask_user.') + function_call['status'] = 'completed' + answer_payload = ( + {'status': 'cancelled', 'answers': {}, 'timed_out': True} + if form_data.timed_out + else {'status': 'answered', 'answers': form_data.answers or {}} + ) + output.append( + { + 'type': 'function_call_output', + 'id': f'fco_{form_data.call_id}', + 'call_id': form_data.call_id, + 'output': [{'type': 'input_text', 'text': JSONCodec.dumps(answer_payload)}], + 'status': 'completed', + } + ) + + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + message_id, + { + 'done': False, + 'output': output, + }, + touch=False, + ) + + event_emitter = await get_event_emitter( + { + 'user_id': chat.user_id, + 'chat_id': chat_id, + 'message_id': message_id, + }, + update_db=False, + ) + if event_emitter: + await event_emitter({'type': 'chat:completion', 'data': {'output': output}}) + + result_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') + } + paused = any( + item.get('type') == 'function_call' + and item.get('call_id') + and item.get('status') in {'pending', 'queued', 'requires_approval'} + and item.get('call_id') not in result_call_ids + for item in output + ) + return {'chat': chat, 'message': message, 'output': output, 'paused': paused} + + +async def build_tool_approval_resume_payload(chat_id: str, message_id: str, chat=None) -> dict: + chat = chat or await Chats.get_chat_by_id(chat_id) + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) + if not assistant_message: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + user_message_id = assistant_message.get('parentId') + user_message = await Chats.get_message_by_id_and_message_id(chat_id, user_message_id) if user_message_id else None + if not user_message: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call parent message is missing.') + + chat_data = chat.chat or {} + message_meta = assistant_message.get('meta') if isinstance(assistant_message.get('meta'), dict) else {} + chat_params = chat_data.get('params') if isinstance(chat_data.get('params'), dict) else {} + params = { + **chat_params, + **(message_meta.get('params') if isinstance(message_meta.get('params'), dict) else {}), + } + current_approval_mode = chat_params.get('tool_approval_mode') + if current_approval_mode in {'ask', 'full'}: + params['tool_approval_mode'] = current_approval_mode + if 'tool_approval_mode' not in params: + params['tool_approval_mode'] = 'ask' + + model_id = assistant_message.get('model') or next(iter(chat_data.get('models') or []), None) + if not model_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call message model is missing.') + + messages = [] + if params.get('system'): + messages.append({'role': 'system', 'content': params.get('system')}) + + return { + 'stream': params.get('stream_response', True), + 'model': model_id, + 'messages': messages, + 'params': params, + 'files': message_meta.get('files') or chat_data.get('files') or None, + 'filter_ids': message_meta.get('filter_ids') or None, + 'tool_ids': message_meta.get('tool_ids') or None, + 'skill_ids': message_meta.get('skill_ids') or None, + 'terminal_id': message_meta.get('terminal_id') or None, + 'tool_servers': message_meta.get('tool_servers') or None, + 'features': message_meta.get('features') or {}, + 'variables': message_meta.get('variables') or {}, + 'chat_variables': chat.variables, + 'session_id': message_meta.get('session_id'), + 'chat_id': chat_id, + 'id': message_id, + 'parent_id': user_message.get('parentId'), + 'user_message': user_message, + 'assistant_message_id': message_id, + } diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 3f7d2aa094..4ab5c96504 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -4,7 +4,6 @@ import asyncio import base64 import copy import inspect -import json import logging import os import re @@ -41,14 +40,13 @@ from open_webui.env import ( REDIS_KEY_PREFIX, ) from open_webui.models.access_grants import AccessGrants -from open_webui.models.chats import Chats from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.tools import Tools from open_webui.models.users import UserModel -from open_webui.utils.chat_id import is_saved_chat_id from open_webui.tools.builtin import ( add_memory, + ask_user, calculate_timestamp, create_automation, create_calendar_event, @@ -65,8 +63,8 @@ from open_webui.tools.builtin import ( grep_chat_files, grep_knowledge_files, kb_exec, - list_chat_files, list_automations, + list_chat_files, list_knowledge, list_knowledge_bases, list_memories, @@ -103,25 +101,29 @@ from open_webui.tools.builtin import ( write_note, ) from open_webui.utils.access_control import has_access, has_connection_access, has_permission -from open_webui.utils.headers import get_custom_headers, include_user_info_headers +from open_webui.utils.chat_id import is_saved_chat_id +from open_webui.utils.headers import ( + bearer_auth_header, + get_custom_headers, + include_user_info_headers, + normalize_bearer_token, +) +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import is_string_allowed from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id -from open_webui.utils.terminals import get_terminal_server_url +from open_webui.utils.terminals import ( + TERMINAL_CONTEXT_HEADER, + get_terminal_server_url, + terminal_context_available, + terminal_context_config, + terminal_context_id, +) from pydantic import BaseModel, Field, create_model from pydantic.fields import FieldInfo log = logging.getLogger(__name__) -def normalize_bearer_token(token: Any) -> str: - return token.strip() if isinstance(token, str) else token or '' - - -def bearer_auth_header(token: Any) -> dict[str, str]: - token = normalize_bearer_token(token) - return {'Authorization': f'Bearer {token}'} if token else {} - - async def build_tool_server_headers( connection: dict, request, @@ -436,7 +438,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr ) headers.setdefault('Content-Type', 'application/json') - async def make_tool_function(function_name, tool_server_data, headers): + async def make_tool_function(function_name, tool_server_data, headers, cookies): async def tool_function(**kwargs): return await execute_tool_server( url=tool_server_data['url'], @@ -449,7 +451,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr return tool_function - tool_function = await make_tool_function(function_name, tool_server_data, headers) + tool_function = await make_tool_function(function_name, tool_server_data, headers, cookies) callable = await get_async_tool_function_and_apply_extra_params( tool_function, @@ -518,7 +520,7 @@ def get_attached_knowledge(model: dict, metadata: dict) -> list[dict]: async def get_builtin_tools( - request: Request, extra_params: dict, features: dict = None, model: dict = None + request: Request, extra_params: dict, features: dict = None, model: dict = None, is_note_chat: bool = False ) -> dict[str, dict]: """ Get built-in tools for native function calling. @@ -535,9 +537,9 @@ async def get_builtin_tools( # Helper to check if a builtin tool category is enabled via meta.builtinTools # Defaults to True if not specified (backward compatible) - def is_builtin_tool_enabled(category: str) -> bool: + def is_builtin_tool_enabled(category: str, default: bool = True) -> bool: builtin_tools = model.get('info', {}).get('meta', {}).get('builtinTools', {}) - return builtin_tools.get(category, True) + return builtin_tools.get(category, default) # Helper to check user-level feature permission (admins always pass) user = extra_params.get('__user__', {}) @@ -577,6 +579,9 @@ async def get_builtin_tools( if is_builtin_tool_enabled('time'): builtin_functions.extend([get_current_timestamp, calculate_timestamp]) + if is_builtin_tool_enabled('user_input', True): + builtin_functions.append(ask_user) + metadata = extra_params.get('__metadata__') or {} chat_files = metadata.get('files') or extra_params.get('__files__') or [] has_chat_files = any( @@ -709,13 +714,8 @@ async def get_builtin_tools( ): builtin_functions.append(execute_code) - chat_id = metadata.get('chat_id') or '' - chat = None - if is_saved_chat_id(chat_id): - chat = await Chats.get_chat_by_id(chat_id) - # Notes tools - search, view, create, and update user's notes - if (chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') or ( + if is_note_chat or ( is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes') ): builtin_functions.extend([search_notes, view_note, write_note, replace_note_content]) @@ -737,7 +737,7 @@ async def get_builtin_tools( # Task management - break down complex work into trackable steps # Task state is stored on the chats row; local/channel IDs do not have one. - if is_builtin_tool_enabled('tasks') and is_saved_chat_id(chat_id): + if is_builtin_tool_enabled('tasks') and is_saved_chat_id(metadata.get('chat_id')): builtin_functions.extend([create_tasks, update_task]) # Automation tools - create and manage scheduled automations from chat @@ -842,17 +842,26 @@ def parse_docstring(docstring): return {} # Regex to match `:param name: description` format - param_pattern = re.compile(r':param (\w+):\s*(.+)') + param_pattern = re.compile(r':param (\w+):\s*(.*)') param_descriptions = {} + current_param = None for line in docstring.splitlines(): - match = param_pattern.match(line.strip()) - if not match: + line = line.strip() + match = param_pattern.match(line) + if match: + param_name, param_description = match.groups() + current_param = None if param_name.startswith('__') else param_name + if current_param: + param_descriptions[current_param] = param_description continue - param_name, param_description = match.groups() - if param_name.startswith('__'): + + if line.startswith(':'): + current_param = None continue - param_descriptions[param_name] = param_description + + if current_param and line: + param_descriptions[current_param] = '\n'.join(filter(None, [param_descriptions[current_param], line])) return param_descriptions @@ -939,6 +948,32 @@ def clean_openai_tool_schema(spec: dict) -> dict: return cleaned_spec +def add_terminal_display_file_inline_param(spec: dict) -> dict: + spec = copy.deepcopy(spec) + if spec.get('name') != 'display_file': + return spec + + spec['description'] = ( + f'{spec.get("description", "")} ' + 'Set inline=true when the file should be shown inline in the chat message instead of opening the file viewer. ' + 'Set page for PDF, DOCX, and PPTX files when you want the preview to open at a specific 1-based page or slide. ' + 'After display_file succeeds, do not display the same file again or emit Markdown for it.' + ).strip() + parameters = spec.setdefault('parameters', {'type': 'object', 'properties': {}, 'required': []}) + parameters.setdefault('type', 'object') + properties = parameters.setdefault('properties', {}) + properties['inline'] = { + 'type': 'boolean', + 'description': 'Show the file inline in the chat message instead of opening the file viewer.', + } + properties['page'] = { + 'type': 'integer', + 'minimum': 1, + 'description': 'For PDF, DOCX, and PPTX files, open the preview at this 1-based page or slide number.', + } + return spec + + @cache def get_builtin_function_introspection(func: Callable): try: @@ -949,14 +984,15 @@ def get_builtin_function_introspection(func: Callable): @cache -def build_builtin_tool_spec(func: Callable) -> dict: +def build_builtin_tool_spec_json(func: Callable) -> str: pydantic_model = convert_function_to_pydantic_model(func, get_builtin_function_introspection(func)) spec = convert_pydantic_model_to_openai_function_spec(pydantic_model) - return clean_openai_tool_schema(spec) + return JSONCodec.dumps(clean_openai_tool_schema(spec)) def get_builtin_tool_spec(func: Callable) -> dict: - return copy.deepcopy(build_builtin_tool_spec(func)) + # callers mutate the spec, so parse a fresh copy out of the cached JSON + return JSONCodec.loads(build_builtin_tool_spec_json(func)) def get_functions_from_tool(tool: object) -> list[Callable]: @@ -1141,7 +1177,7 @@ async def set_tool_servers(request: Request): try: if request.app.state.redis is not None: await request.app.state.redis.set( - f'{REDIS_KEY_PREFIX}:tool_servers', json.dumps(request.app.state.TOOL_SERVERS) + f'{REDIS_KEY_PREFIX}:tool_servers', JSONCodec.dumps(request.app.state.TOOL_SERVERS) ) except Exception as e: log.error(f'Error caching tool_servers to Redis: {e}') @@ -1151,15 +1187,17 @@ async def set_tool_servers(request: Request): async def get_tool_servers(request: Request): try: - tool_servers = [] + tool_servers = None if request.app.state.redis is not None: try: - tool_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers')) - request.app.state.TOOL_SERVERS = tool_servers + data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers') + if data is not None: + tool_servers = JSONCodec.loads(data) + request.app.state.TOOL_SERVERS = tool_servers except Exception as e: log.error(f'Error fetching tool_servers from Redis: {e}') - if not tool_servers: + if tool_servers is None: tool_servers = await set_tool_servers(request) return tool_servers @@ -1187,7 +1225,7 @@ async def get_terminal_cwd( data = await resp.json() return data.get('cwd') except Exception as e: - log.debug(f'Failed to fetch terminal CWD: {e}') + log.debug('Failed to fetch terminal CWD: %s', e) return None @@ -1226,7 +1264,7 @@ async def get_terminal_system_prompt( data = await resp.json() return data.get('prompt') except Exception as e: - log.debug(f'Failed to fetch terminal system prompt: {e}') + log.debug('Failed to fetch terminal system prompt: %s', e) return None @@ -1286,7 +1324,7 @@ async def set_terminal_servers(request: Request): if request.app.state.redis is not None: await request.app.state.redis.set( - f'{REDIS_KEY_PREFIX}:terminal_servers', json.dumps(request.app.state.TERMINAL_SERVERS) + f'{REDIS_KEY_PREFIX}:terminal_servers', JSONCodec.dumps(request.app.state.TERMINAL_SERVERS) ) return request.app.state.TERMINAL_SERVERS @@ -1294,15 +1332,17 @@ async def set_terminal_servers(request: Request): async def get_terminal_servers(request: Request): """Return cached terminal server specs, loading if needed.""" - terminal_servers = [] + terminal_servers = None if request.app.state.redis is not None: try: - terminal_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers')) - request.app.state.TERMINAL_SERVERS = terminal_servers + data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers') + if data is not None: + terminal_servers = JSONCodec.loads(data) + request.app.state.TERMINAL_SERVERS = terminal_servers except Exception as e: log.error(f'Error fetching terminal_servers from Redis: {e}') - if not terminal_servers: + if terminal_servers is None: terminal_servers = await set_terminal_servers(request) return terminal_servers @@ -1361,10 +1401,21 @@ async def get_terminal_tools( # Use chat_id as the per-session key for cwd tracking metadata = extra_params.get('__metadata__', {}) + terminal_context = 'automation' if metadata.get('automation_id') else 'chat' + if not terminal_context_available(connection, terminal_context): + raise RuntimeError(f"Terminal server '{terminal_id}' is not available for {terminal_context}") + session_id = metadata.get('chat_id') if session_id: headers['X-Session-Id'] = session_id + context_id = terminal_context_id(connection, metadata, terminal_context) + config = terminal_context_config(connection, terminal_context) + if isinstance(config, dict) and config.get('context_id') in {'chat_id', 'automation_id'} and not context_id: + raise RuntimeError(f"Terminal server '{terminal_id}' requires a saved {terminal_context} context") + if context_id: + headers[TERMINAL_CONTEXT_HEADER] = context_id + # Fetch live with the user's credentials so prompt changes apply without a restart terminal_cwd, system_prompt = await asyncio.gather( get_terminal_cwd(server_data['url'], headers, cookies), @@ -1376,7 +1427,7 @@ async def get_terminal_tools( tools_dict = {} for spec in specs: function_name = spec['name'] - tool_spec = clean_openai_tool_schema(spec) + tool_spec = clean_openai_tool_schema(add_terminal_display_file_inline_param(spec)) if function_name == 'run_command' and terminal_cwd: tool_spec['description'] = ( @@ -1385,12 +1436,15 @@ async def get_terminal_tools( async def make_tool_function(fn_name, srv_data, hdrs, cks): async def tool_function(**kwargs): + params = dict(kwargs) + if fn_name == 'display_file': + params.pop('page', None) return await execute_tool_server( url=srv_data['url'], headers=hdrs, cookies=cks, name=fn_name, - params=kwargs, + params=params, server_data=srv_data, ) @@ -1434,11 +1488,15 @@ async def get_tool_server_data(url: str, headers: dict | None) -> dict[str, Any] res = yaml.safe_load(text_content) else: try: - res = json.loads(text_content) - except json.JSONDecodeError: + res = JSONCodec.loads(text_content) + except JSONCodec.JSONDecodeError: # Fall back to YAML for non-.yml URLs that aren't valid JSON res = yaml.safe_load(text_content) + except (aiohttp.ClientConnectionError, TimeoutError) as err: + error = str(err) or type(err).__name__ + log.error(f'Could not fetch tool server spec from {url}: {error}') + raise Exception(error) except Exception as err: log.exception(f'Could not fetch tool server spec from {url}') if isinstance(err, dict) and 'detail' in err: @@ -1447,7 +1505,7 @@ async def get_tool_server_data(url: str, headers: dict | None) -> dict[str, Any] error = str(err) raise Exception(error) - log.debug(f'Fetched data: {res}') + log.debug('Fetched data: %s', res) return res @@ -1491,7 +1549,7 @@ async def get_tool_servers_data(servers: list[dict[str, Any]]) -> list[dict[str, # Use provided JSON spec spec_json = None try: - spec_json = json.loads(server.get('spec', '')) + spec_json = JSONCodec.loads(server.get('spec', '')) except Exception as e: log.error(f'Error parsing JSON spec for tool server {id}: {e}') @@ -1523,7 +1581,9 @@ async def get_tool_servers_data(servers: list[dict[str, Any]]) -> list[dict[str, response = { 'openapi': response, 'info': response.get('info', {}), - 'specs': convert_openapi_to_tool_payload(response), + 'specs': [ + add_terminal_display_file_inline_param(spec) for spec in convert_openapi_to_tool_payload(response) + ], } openapi_data = response.get('openapi', {}) diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py index a672bd298b..333cf56d95 100644 --- a/backend/open_webui/utils/validate.py +++ b/backend/open_webui/utils/validate.py @@ -14,10 +14,13 @@ _USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$') _mime_suffixes = '|'.join(re.escape(t.split('/')[-1]) for t in sorted(PROFILE_IMAGE_ALLOWED_MIME_TYPES)) _SAFE_DATA_URI_RE = re.compile(rf'^data:image/({_mime_suffixes});base64,', re.IGNORECASE) -# Exact relative paths accepted as profile images. These are the only +# Exact relative paths accepted as profile images. These are the only # static-asset paths OWUI itself assigns; no prefix/wildcard matching is # used so that arbitrary relative paths cannot trigger authenticated GETs # against internal endpoints when rendered as ```` sources. +# LICENSE covers the Open WebUI favicon fallback paths below. Do not alter, +# remove, obscure, or replace them except as LICENSE permits: +# https://docs.openwebui.com/license. _SAFE_STATIC_PATHS = frozenset( { '/user.png', diff --git a/backend/open_webui/utils/valves.py b/backend/open_webui/utils/valves.py index c6e2212059..338f272bd7 100644 --- a/backend/open_webui/utils/valves.py +++ b/backend/open_webui/utils/valves.py @@ -1,11 +1,11 @@ import base64 import hashlib -import json import logging from functools import lru_cache from cryptography.fernet import Fernet, InvalidToken from open_webui.env import ENABLE_VALVE_ENCRYPTION, WEBUI_SECRET_KEY +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -21,7 +21,7 @@ def _fernet() -> Fernet: def encrypt_valves(valves: dict) -> dict | str: if not ENABLE_VALVE_ENCRYPTION: return valves - return _fernet().encrypt(json.dumps(valves).encode()).decode() + return _fernet().encrypt(JSONCodec.dumps(valves).encode()).decode() def decrypt_valves(valves) -> dict: @@ -33,8 +33,8 @@ def decrypt_valves(valves) -> dict: return {} try: - decrypted = json.loads(_fernet().decrypt(valves.encode()).decode()) - except (InvalidToken, json.JSONDecodeError) as e: + decrypted = JSONCodec.loads(_fernet().decrypt(valves.encode()).decode()) + except (InvalidToken, JSONCodec.JSONDecodeError) as e: log.warning('Failed to decrypt valves: %s', type(e).__name__) return {} diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index 9430c6ff67..122e83da49 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -1,5 +1,4 @@ import asyncio -import json import logging from open_webui.config import WEBUI_FAVICON_URL @@ -9,6 +8,7 @@ from open_webui.env import ( VERSION, ) from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url +from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -29,7 +29,7 @@ def _event_text(message: str, description: str | None = None, event_data: dict | async def post_webhook(name: str, url: str, message: str, event_data: dict, description: str | None = None) -> bool: try: - log.debug(f'post_webhook: {url}, {message}, {event_data}') + log.debug('post_webhook: %s, %s, %s', url, message, event_data) # Block private-IP / loopback / cloud-metadata targets — the URL is # caller-controlled (user notification settings under # ENABLE_USER_WEBHOOKS, automation notification triggers). @@ -54,7 +54,7 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict, desc if isinstance(user_data, dict): user_dict = user_data else: - user_dict = json.loads(user_data) + user_dict = JSONCodec.loads(user_data) facts = [{'name': key, 'value': value} for key, value in user_dict.items()] if event_data.get('event'): facts.insert(0, {'name': 'event', 'value': event_data.get('event')}) @@ -69,6 +69,9 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict, desc { 'activityTitle': message, 'activitySubtitle': f'{name} ({VERSION}) - {action}', + # LICENSE covers this Open WebUI webhook logo. + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. 'activityImage': WEBUI_FAVICON_URL, 'text': description, 'facts': facts, @@ -80,7 +83,7 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict, desc else: payload = event_data - log.debug(f'payload: {payload}') + log.debug('payload: %s', payload) async with get_ssrf_safe_session() as session: async with session.post( url, @@ -90,7 +93,7 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict, desc ) as r: r_text = await r.text() r.raise_for_status() - log.debug(f'r.text: {r_text}') + log.debug('r.text: %s', r_text) return True except Exception as e: diff --git a/backend/requirements.txt b/backend/requirements.txt index 39f666f6d9..a8b091c37b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,7 +16,7 @@ joserfc==1.7.4 requests==2.34.2 regex==2026.5.9 # supports a per-search timeout, which `re` does not aiohttp==3.13.5 # do not update to 3.13.3 - broken -aiodns==4.0.4 # makes aiohttp resolve DNS on the event loop instead of the threadpool +aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER async-timeout==5.0.1 aiocache==0.12.3 aiofiles==25.1.0 @@ -73,6 +73,7 @@ pypdf==6.7.5 fpdf2==2.8.7 pymdown-extensions==10.21.3 docx2txt==0.9 +python-docx==1.2.0 python-pptx==1.0.2 msoffcrypto-tool==6.0.0 unstructured==0.22.31 @@ -140,12 +141,6 @@ av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion htt colbert-ai==0.2.22 - -## Tests -docker~=7.1.0 -pytest~=8.4.1 -pytest-docker~=3.2.5 - ## LDAP ldap3==2.9.1 diff --git a/backend/start.sh b/backend/start.sh index 0846273b89..634f4bcc1b 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -72,7 +72,7 @@ if [[ -n "${SPACE_ID:-}" ]]; then if [[ -n "${ADMIN_USER_EMAIL:-}" && -n "${ADMIN_USER_PASSWORD:-}" ]]; then echo "Creating admin user for Space..." WEBUI_SECRET_KEY="${WEBUI_SECRET_KEY:-}" \ - uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" & + uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --ws-per-message-deflate "${UVICORN_WS_PER_MESSAGE_DEFLATE:-true}" & webui_pid=$! echo "Waiting for server to become healthy..." @@ -102,7 +102,7 @@ UVICORN_WORKERS="${UVICORN_WORKERS:-1}" if [[ "$#" -gt 0 ]]; then ARGS=("$@") else - ARGS=(--workers "$UVICORN_WORKERS") + ARGS=(--workers "$UVICORN_WORKERS" --ws-per-message-deflate "${UVICORN_WS_PER_MESSAGE_DEFLATE:-true}") fi exec env WEBUI_SECRET_KEY="${WEBUI_SECRET_KEY:-}" \ diff --git a/backend/start_windows.bat b/backend/start_windows.bat index b86beb11e8..4a57d34694 100644 --- a/backend/start_windows.bat +++ b/backend/start_windows.bat @@ -35,20 +35,26 @@ IF "%WEBUI_SECRET_KEY_LENGTH%" == "" ( IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " ( echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable. - IF NOT EXIST "%KEY_FILE%" ( + IF NOT EXIST "!KEY_FILE!" ( echo Generating WEBUI_SECRET_KEY :: Generate a random value to use as a WEBUI_SECRET_KEY in case the user didn't provide one - SET /p WEBUI_SECRET_KEY=>%KEY_FILE% + SET "CHARSET=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + SET "WEBUI_SECRET_KEY=" + FOR /L %%i IN (1,1,!WEBUI_SECRET_KEY_LENGTH!) DO ( + SET /A "INDEX=!RANDOM! %% 62" + FOR %%j IN (!INDEX!) DO SET "WEBUI_SECRET_KEY=!WEBUI_SECRET_KEY!!CHARSET:~%%j,1!" + ) + "!KEY_FILE!" echo WEBUI_SECRET_KEY generated ) - echo Loading WEBUI_SECRET_KEY from %KEY_FILE% - SET /p WEBUI_SECRET_KEY=<%KEY_FILE% + echo Loading WEBUI_SECRET_KEY from !KEY_FILE! + SET /p WEBUI_SECRET_KEY=<"!KEY_FILE!" ) :: Execute uvicorn SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" IF "%UVICORN_WORKERS%"=="" SET UVICORN_WORKERS=1 -uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips %FORWARDED_ALLOW_IPS% --workers %UVICORN_WORKERS% --ws auto +if "%UVICORN_WS_PER_MESSAGE_DEFLATE%" == "" set "UVICORN_WS_PER_MESSAGE_DEFLATE=true" +uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips %FORWARDED_ALLOW_IPS% --workers %UVICORN_WORKERS% --ws auto --ws-per-message-deflate %UVICORN_WS_PER_MESSAGE_DEFLATE% :: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto diff --git a/package-lock.json b/package-lock.json index 994e0fa266..cd0cf349f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.11.0", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.11.0", + "version": "0.11.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -52,6 +52,7 @@ "codemirror-lang-hcl": "^0.1.0", "crc-32": "^1.2.2", "dayjs": "^1.11.10", + "docx-preview": "^0.4.0", "dompurify": "^3.2.6", "eventsource-parser": "^1.1.2", "fast-deep-equal": "^3.1.3", @@ -76,7 +77,6 @@ "mammoth": "^1.11.0", "marked": "^9.1.0", "mermaid": "^11.10.1", - "paneforge": "^0.0.6", "panzoom": "^9.4.3", "pdfjs-dist": "^5.4.149", "prosemirror-collab": "^1.3.1", @@ -7640,6 +7640,15 @@ "node": ">=6.0.0" } }, + "node_modules/docx-preview": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.0.tgz", + "integrity": "sha512-OdKtE/uj3M4RfGarLkGjahUzRg8/kBp0Sraj1r1NAY1tp/sTpHOBqDrzVf9onMBt9vxP6SdQ6bpLCUCsFwjgcA==", + "license": "Apache-2.0", + "dependencies": { + "jszip": ">=3.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -11178,24 +11187,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/nanoid": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", - "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -11492,18 +11483,6 @@ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, - "node_modules/paneforge": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/paneforge/-/paneforge-0.0.6.tgz", - "integrity": "sha512-jYeN/wdREihja5c6nK3S5jritDQ+EbCqC5NrDo97qCZzZ9GkmEcN5C0ZCjF4nmhBwkDKr6tLIgz4QUKWxLXjAw==", - "license": "MIT", - "dependencies": { - "nanoid": "^5.0.4" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.1" - } - }, "node_modules/panzoom": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.3.tgz", diff --git a/package.json b/package.json index 8864d55895..85b7735b46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.11.0", + "version": "0.11.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -96,6 +96,7 @@ "codemirror-lang-hcl": "^0.1.0", "crc-32": "^1.2.2", "dayjs": "^1.11.10", + "docx-preview": "^0.4.0", "dompurify": "^3.2.6", "eventsource-parser": "^1.1.2", "fast-deep-equal": "^3.1.3", @@ -120,7 +121,6 @@ "mammoth": "^1.11.0", "marked": "^9.1.0", "mermaid": "^11.10.1", - "paneforge": "^0.0.6", "panzoom": "^9.4.3", "pdfjs-dist": "^5.4.149", "prosemirror-collab": "^1.3.1", diff --git a/pyproject.toml b/pyproject.toml index 078f02eac7..5b6b3e0749 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "requests==2.34.2", "aiohttp==3.13.5", # do not update to 3.13.3 - broken - "aiodns==4.0.4", # makes aiohttp resolve DNS on the event loop instead of the threadpool + "aiodns==3.6.1", # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER "async-timeout==5.0.1", "aiocache==0.12.3", "aiofiles==25.1.0", @@ -82,6 +82,7 @@ dependencies = [ "fpdf2==2.8.7", "pymdown-extensions==10.21.3", "docx2txt==0.9", + "python-docx==1.2.0", "python-pptx==1.0.2", "msoffcrypto-tool==6.0.0", "nltk==3.9.4", @@ -155,10 +156,6 @@ all = [ "pymongo==4.17.0", "psycopg2-binary==2.9.12", "pgvector==0.4.2", - "moto[s3]>=5.0.26", - "docker~=7.1.0", - "pytest~=8.4.1", - "pytest-docker~=3.2.5", "playwright==1.60.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary "elasticsearch==9.4.1", diff --git a/src/app.css b/src/app.css index 1d9c74770e..a5be2c2d78 100644 --- a/src/app.css +++ b/src/app.css @@ -64,6 +64,16 @@ code { @apply bg-gray-50/40 dark:bg-gray-800/40; } +/* visibility:hidden is not focusable, so a keyboard user could never reveal these */ +.hover-reveal { + @apply opacity-0 pointer-events-none; +} + +.group:hover .hover-reveal, +.group:focus-within .hover-reveal { + @apply opacity-100 pointer-events-auto; +} + .marked a { @apply underline; } @@ -399,6 +409,17 @@ input[type='number'] { outline: none; } +:focus-visible:not(.ProseMirror):not([contenteditable='true']):not(input):not(textarea), +.focus-ring:focus-visible { + outline: 2px solid theme(--color-blue-500); + outline-offset: -2px; +} + +html.high-contrast :is(input, textarea, [contenteditable='true'], .ProseMirror):focus-visible { + outline: 2px solid theme(--color-blue-500); + outline-offset: -2px; +} + .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; @@ -694,7 +715,7 @@ html.high-contrast.dark .ProseMirror p.is-editor-empty:first-child::before { /* this is a rough fix for the first cursor position when the first paragraph is empty */ .ProseMirror > .ProseMirror-yjs-cursor:first-child { - margin-top: 16px; + margin-top: 1rem; } /* This gives the remote user caret. The colors are automatically overwritten*/ .ProseMirror-yjs-cursor { @@ -712,12 +733,12 @@ html.high-contrast.dark .ProseMirror p.is-editor-empty:first-child::before { position: absolute; top: -1.05em; left: -1px; - font-size: 13px; + font-size: 0.8125rem; background-color: rgb(250, 129, 0); user-select: none; color: white; - padding-left: 2px; - padding-right: 2px; + padding-left: 0.125rem; + padding-right: 0.125rem; white-space: nowrap; } @@ -734,7 +755,7 @@ body { /* Position the handle relative to each LI */ .pm-li--with-handle { position: relative; - margin-left: 12px; /* make space for the handle */ + margin-left: 0.75rem; /* make space for the handle */ } .tiptap ul[data-type='taskList'] .pm-list-drag-handle { @@ -744,16 +765,16 @@ body { /* The drag handle itself */ .pm-list-drag-handle { position: absolute; - left: -36px; /* pull into the left gutter */ + left: -2.25rem; /* pull into the left gutter */ top: 1px; - width: 18px; - height: 18px; + width: 1.125rem; + height: 1.125rem; display: inline-flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 0.75rem; line-height: 1; - border-radius: 4px; + border-radius: 0.25rem; cursor: grab; user-select: none; opacity: 0.35; @@ -763,7 +784,7 @@ body { } .tiptap ul[data-type='taskList'] .pm-list-drag-handle { - left: -16px; /* pull into the left gutter more to avoid the checkbox */ + left: -1rem; /* pull into the left gutter more to avoid the checkbox */ } .pm-list-drag-handle:active { diff --git a/src/app.html b/src/app.html index 285164d02f..667808e011 100644 --- a/src/app.html +++ b/src/app.html @@ -2,6 +2,10 @@ + + { let error = null; let res = null; @@ -167,6 +168,7 @@ export const getChannelMembersById = async ( `${WEBUI_API_BASE_URL}/channels/${channel_id}/members?${searchParams.toString()}`, { method: 'GET', + signal, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` @@ -178,6 +180,7 @@ export const getChannelMembersById = async ( return res.json(); }) .catch((err) => { + if (signal?.aborted) return null; console.error(err); error = err.detail; return null; diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index b55df4b72d..5dca7283ba 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -1,6 +1,14 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; import { getTimeRange } from '$lib/utils'; +const getErrorDetail = (err: any) => { + if (Array.isArray(err?.detail)) { + return err.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', '); + } + + return err?.detail ?? err; +}; + export const getChatConfig = async (token: string) => { let error = null; @@ -17,7 +25,7 @@ export const getChatConfig = async (token: string) => { return res.json(); }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -46,7 +54,7 @@ export const updateChatConfig = async (token: string, config: object) => { return res.json(); }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -84,7 +92,7 @@ export const createNewChat = async ( return res.json(); }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -179,7 +187,7 @@ export const importChats = async (token: string, chats: object[]) => { return res.json(); }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -228,7 +236,7 @@ export const getChatList = async ( return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -286,7 +294,7 @@ export const getChatListByUserId = async ( return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -335,7 +343,7 @@ export const getArchivedChatList = async ( return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -366,7 +374,7 @@ export const getArchivedChatCount = async (token: string = '') => { return res.json(); }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -408,7 +416,7 @@ export const getSharedChatList = async (token: string = '', page: number = 1, fi return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -496,7 +504,7 @@ export const getChatListBySearchText = async (token: string, text: string, page: return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -530,7 +538,7 @@ export const getChatsByFolderId = async (token: string, folderId: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -569,7 +577,7 @@ export const getChatListByFolderId = async (token: string, folderId: string, pag return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -600,7 +608,7 @@ export const getAllArchivedChats = async (token: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -631,7 +639,7 @@ export const getAllUserChats = async (token: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -662,7 +670,7 @@ export const getAllTags = async (token: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -693,7 +701,7 @@ export const getPinnedChatList = async (token: string = '') => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -730,7 +738,7 @@ export const getChatListByTagName = async (token: string = '', tagName: string) return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -796,7 +804,7 @@ export const getChatByShareId = async (token: string, share_id: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1080,7 +1088,7 @@ export const shareChatById = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1115,7 +1123,7 @@ export const updateChatFolderIdById = async (token: string, id: string, folderId return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1147,7 +1155,7 @@ export const archiveChatById = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1179,7 +1187,7 @@ export const deleteSharedChatById = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1214,7 +1222,7 @@ export const updateChatAccessGrants = async (token: string, id: string, accessGr return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1246,7 +1254,7 @@ export const getChatAccessGrants = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1287,7 +1295,7 @@ export const updateChatById = async ( return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1320,7 +1328,7 @@ export const compactChatById = async (token: string, id: string, model?: string return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1349,7 +1357,47 @@ export const deleteChatMessageById = async (token: string, id: string, messageId return res.json(); }) .catch((err) => { - error = err; + error = getErrorDetail(err); + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const resolveChatMessageToolCall = async ( + token: string, + id: string, + messageId: string, + callId: string, + action: 'approve' | 'reject' | 'answer', + options: { answers?: unknown; timed_out?: boolean } = {} +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/messages/${messageId}/resolve`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + call_id: callId, + action, + ...options + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = getErrorDetail(err); console.error(err); return null; }); @@ -1412,7 +1460,7 @@ export const getTagsById = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1481,7 +1529,7 @@ export const deleteTagById = async (token: string, id: string, tagName: string) return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; @@ -1585,7 +1633,7 @@ export const exportChatStats = async (token: string, page: number = 1, params: o return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -1616,7 +1664,7 @@ export const exportSingleChatStats = async (token: string, chatId: string) => { return json; }) .catch((err) => { - error = err; + error = getErrorDetail(err); console.error(err); return null; }); @@ -1647,7 +1695,7 @@ export const downloadChatStats = async ( } }).catch((err) => { console.error(err); - error = err; + error = getErrorDetail(err); return null; }); diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 4f55ab14be..04d8323177 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1,5 +1,6 @@ import { WEBUI_BASE_URL } from '$lib/constants'; import { convertOpenApiToToolPayload } from '$lib/utils'; +import { normalizeTags } from '$lib/utils/tags'; import { getOpenAIModelsDirect } from './openai'; const TOOL_SERVER_FETCH_TIMEOUT = 10000; @@ -141,8 +142,8 @@ export const getModels = async ( } } - const tags = apiConfig.tags; - if (tags) { + const tags = normalizeTags(apiConfig.tags); + if (tags.length > 0) { for (const model of models) { model.tags = tags; } @@ -937,7 +938,7 @@ export const generateEmoji = async ( throw error; } - const response = res?.choices[0]?.message?.content.replace(/["']/g, '') ?? null; + const response = res?.choices[0]?.message?.content?.replace(/["']/g, '') ?? null; if (response) { if (/\p{Extended_Pictographic}/u.test(response)) { @@ -1758,6 +1759,7 @@ export interface ModelConfig { export interface ModelMeta { toolIds: never[]; description?: string; + hidden?: boolean; capabilities?: object; profile_image_url?: string; } diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index f0a6e4d140..3714e46752 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -955,6 +955,34 @@ export const reindexKnowledgeFiles = async (token: string) => { return res; }; +export const reindexKnowledgeMetadata = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/metadata/reindex`, { + 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 exportKnowledgeById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/memories/index.ts b/src/lib/apis/memories/index.ts index 5ebee8fb47..61547a84b6 100644 --- a/src/lib/apis/memories/index.ts +++ b/src/lib/apis/memories/index.ts @@ -128,6 +128,34 @@ export const queryMemory = async (token: string, content: string) => { return res; }; +export const reindexMemoryVectors = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/memories/reindex`, { + 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 deleteMemoryById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index d18565fec3..b89571428a 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -1,5 +1,18 @@ import { OPENAI_API_BASE_URL, WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; +export const getErrorMessage = (err: any, fallback = 'Server connection failed') => { + const detail = err?.detail; + if (typeof detail === 'string') return detail; + + return ( + detail?.error?.message ?? + detail?.message ?? + err?.error?.message ?? + err?.message ?? + (typeof err === 'string' ? err : fallback) + ); +}; + export const getOpenAIConfig = async (token: string = '') => { let error = null; @@ -17,11 +30,7 @@ export const getOpenAIConfig = async (token: string = '') => { }) .catch((err) => { console.error(err); - if ('detail' in err) { - error = err.detail; - } else { - error = 'Server connection failed'; - } + error = getErrorMessage(err); return null; }); @@ -59,11 +68,7 @@ export const updateOpenAIConfig = async (token: string = '', config: OpenAIConfi }) .catch((err) => { console.error(err); - if ('detail' in err) { - error = err.detail; - } else { - error = 'Server connection failed'; - } + error = getErrorMessage(err); return null; }); @@ -131,9 +136,197 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => { return res; }; +export const getProviderModelCatalog = async (token: string, urlIdx: number) => { + let error = null; + + const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/catalog`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = getErrorMessage(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const downloadProviderModel = async ( + token: string, + urlIdx: number, + model: string, + signal?: AbortSignal +) => { + let error = null; + + const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/download`, { + signal, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ model }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = getErrorMessage(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getProviderModelDownloadStatus = async ( + token: string, + urlIdx: number, + jobId: string, + signal?: AbortSignal +) => { + let error = null; + + const res = await fetch( + `${OPENAI_API_BASE_URL}/models/${urlIdx}/download/status/${encodeURIComponent(jobId)}`, + { + signal, + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = getErrorMessage(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const loadProviderModel = async (token: string, urlIdx: number, model: string) => { + let error = null; + + const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/load`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ model }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = getErrorMessage(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const unloadProviderModel = async ( + token: string, + urlIdx: number, + model: string, + instanceId?: string +) => { + let error = null; + + const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/unload`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ model, ...(instanceId ? { instance_id: instanceId } : {}) }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = getErrorMessage(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteProviderModel = async (token: string, urlIdx: number, model: string) => { + let error = null; + + const res = await fetch( + `${OPENAI_API_BASE_URL}/models/${urlIdx}?${new URLSearchParams({ model })}`, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = getErrorMessage(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const verifyOpenAIConnection = async ( token: string = '', - connection: dict = {}, + connection: Record = {}, direct: boolean = false ) => { const { url, key, config } = connection; @@ -246,7 +439,7 @@ export const generateOpenAIChatCompletion = async ( return res.json(); }) .catch((err) => { - error = err?.detail ?? err; + error = getErrorMessage(err); return null; }); diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index fc5a7e8274..3e3a2d2a00 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -56,6 +56,7 @@ type RAGConfigForm = { ENABLE_GOOGLE_DRIVE_INTEGRATION?: boolean; ENABLE_ONEDRIVE_INTEGRATION?: boolean; EXTERNAL_DOCUMENT_LOADER_HEADERS?: Record; + TIKA_SERVER_VERSION?: string | null; chunk?: ChunkConfigForm; content_extraction?: ContentExtractConfigForm; web_loader_ssl_verification?: boolean; @@ -188,6 +189,8 @@ type OpenAIConfigForm = { url: string; }; +type OllamaConfigForm = OpenAIConfigForm; + type AzureOpenAIConfigForm = { key: string; url: string; @@ -196,10 +199,13 @@ type AzureOpenAIConfigForm = { type EmbeddingModelUpdateForm = { openai_config?: OpenAIConfigForm; + ollama_config?: OllamaConfigForm; azure_openai_config?: AzureOpenAIConfigForm; - embedding_engine: string; - embedding_model: string; - embedding_batch_size?: number; + RAG_EMBEDDING_ENGINE: string; + RAG_EMBEDDING_MODEL: string; + RAG_EMBEDDING_BATCH_SIZE?: number; + ENABLE_ASYNC_EMBEDDING?: boolean; + RAG_EMBEDDING_CONCURRENT_REQUESTS?: number; }; export const updateEmbeddingConfig = async (token: string, payload: EmbeddingModelUpdateForm) => { @@ -330,6 +336,48 @@ export const processYoutubeVideo = async (token: string, url: string) => { return res; }; +export const processUrl = async ( + token: string, + url: string, + collection_name: string | null = null, + process: boolean = true +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (!process) { + searchParams.append('process', 'false'); + } + + const res = await fetch(`${RETRIEVAL_API_BASE_URL}/process/url?${searchParams.toString()}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + url, + collection_name + }) + }) + .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 processWeb = async ( token: string, collection_name: string, diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts index fc1dc24ce1..5185d5efaf 100644 --- a/src/lib/apis/skills/index.ts +++ b/src/lib/apis/skills/index.ts @@ -31,10 +31,13 @@ export const createNewSkill = async (token: string, skill: object) => { return res; }; -export const getSkills = async (token: string = '') => { +export const getSkills = async (token: string = '', query: string | null = null) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/skills/`, { + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index eedbcc3eed..a8f310a962 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -3,6 +3,40 @@ export type FileEntry = { type: 'file' | 'directory'; size?: number; modified?: number; + writable?: boolean; +}; + +export type TerminalFileList = { + entries: FileEntry[]; + writable?: boolean; +}; + +export type TerminalFileSearchResult = FileEntry & { + path: string; +}; + +export type TerminalFileSearchResponse = { + results: TerminalFileSearchResult[]; +}; + +export type TerminalContentMatch = { + line: number; + column: number; + text: string; +}; + +export type TerminalFileMatch = { + path: string; + relative_path: string; + name: string; + type: 'file' | 'directory'; + name_match: boolean; + content_matches: TerminalContentMatch[]; +}; + +export type TerminalFileMatchesResponse = { + results: TerminalFileMatch[]; + next_offset: number | null; }; export type ListeningPort = { @@ -32,10 +66,30 @@ const bearerHeaders = (apiKey: string): Record => ({ Authorization: `Bearer ${apiKey.trim()}` }); +const joinTerminalPath = (base: string, child: string) => { + if (!child) return base; + if (child.startsWith('/') || /^[A-Za-z]:[\\/]/.test(child)) return child; + return `${base.replace(/[\\/]+$/, '')}/${child.replace(/^[\\/]+/, '')}`; +}; + +const basename = (path: string) => + path.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? path; + +const hasHiddenPathPart = (path: string) => + path + .replace(/\\/g, '/') + .split('/') + .some((part) => part.startsWith('.')); + export type TerminalServer = { id: string; url: string; name: string; + contexts?: Record; + config?: { + chat_uploads?: 'default' | 'filesystem'; + [key: string]: unknown; + }; }; export const getTerminalServers = async (token: string): Promise => { @@ -84,7 +138,7 @@ export const listFiles = async ( apiKey: string, path: string = '/', sessionId?: string -): Promise => { +): Promise => { // The endpoint uses `directory` as the query param name const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`; const headers: Record = bearerHeaders(apiKey); @@ -98,7 +152,97 @@ export const listFiles = async ( console.error('open-terminal listFiles error:', err); return null; }); - return res?.entries ?? null; + return res?.entries ? { entries: res.entries, writable: res.writable } : null; +}; + +export const searchFiles = async ( + baseUrl: string, + apiKey: string, + query: string, + path: string = '.', + limit: number = 20, + type: 'file' | 'directory' | 'any' = 'any', + sessionId?: string, + showHidden: boolean = false +): Promise => { + const headers: Record = bearerHeaders(apiKey); + if (sessionId) headers['X-Session-Id'] = sessionId; + + const searchParams = new URLSearchParams({ + query, + path, + limit: String(limit), + type, + show_hidden: String(showHidden) + }); + const base = baseUrl.replace(/\/$/, ''); + const searchRes = await fetch(`${base}/files/search?${searchParams.toString()}`, { + headers + }).catch(() => null); + + if (searchRes?.ok) { + const json = await searchRes.json().catch(() => null); + if (Array.isArray(json?.results)) return { results: json.results }; + } + + const globParams = new URLSearchParams({ + pattern: query.trim() ? `*${query.trim()}*` : '*', + path, + type, + max_results: String(limit) + }); + const globRes = await fetch(`${base}/files/glob?${globParams.toString()}`, { + headers + }).catch((err) => { + console.error('open-terminal searchFiles error:', err); + return null; + }); + if (!globRes?.ok) return null; + + const json = await globRes.json().catch(() => null); + const root = json?.path ?? path; + return { + results: (json?.matches ?? []) + .filter((item: FileEntry & { path: string }) => showHidden || !hasHiddenPathPart(item.path)) + .map((item: FileEntry & { path: string }) => ({ + path: joinTerminalPath(root, item.path), + name: basename(item.path), + type: item.type, + size: item.size, + modified: item.modified + })) + }; +}; + +export const getFileMatches = async ( + baseUrl: string, + apiKey: string, + query: string, + path: string = '.', + showHidden: boolean = false, + offset: number = 0, + sessionId?: string, + signal?: AbortSignal +): Promise => { + const headers: Record = bearerHeaders(apiKey); + if (sessionId) headers['X-Session-Id'] = sessionId; + + const params = new URLSearchParams({ + query, + path, + show_hidden: String(showHidden), + offset: String(offset) + }); + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/files/matches?${params.toString()}`, { + headers, + signal + }).catch((err) => { + if (err?.name !== 'AbortError') console.error('open-terminal getFileMatches error:', err); + return null; + }); + if (!res?.ok) return null; + const json = await res.json().catch(() => null); + return Array.isArray(json?.results) ? json : null; }; export const readFile = async ( diff --git a/src/lib/apis/tools/index.ts b/src/lib/apis/tools/index.ts index 5d26e50fee..8378299923 100644 --- a/src/lib/apis/tools/index.ts +++ b/src/lib/apis/tools/index.ts @@ -65,10 +65,13 @@ export const loadToolByUrl = async (token: string = '', url: string) => { return res; }; -export const getTools = async (token: string = '') => { +export const getTools = async (token: string = '', query: string | null = null) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/tools/`, { + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index 82556e7822..0ab07bda1f 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -198,7 +198,8 @@ export const searchUsers = async ( query?: string, orderBy?: string, direction?: string, - page = 1 + page = 1, + signal?: AbortSignal ) => { let error = null; let res = null; @@ -221,6 +222,7 @@ export const searchUsers = async ( res = await fetch(`${WEBUI_API_BASE_URL}/users/search?${searchParams.toString()}`, { method: 'GET', + signal, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` @@ -231,6 +233,7 @@ export const searchUsers = async ( return res.json(); }) .catch((err) => { + if (signal?.aborted) return null; console.error(err); error = err.detail; return null; @@ -271,9 +274,9 @@ export const getAllUsers = async (token: string) => { return res; }; -export const getUserSettings = async (token: string) => { +export const getUserSettings = async (token: string, raw = false) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings${raw ? '?raw=true' : ''}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -286,7 +289,7 @@ export const getUserSettings = async (token: string) => { }) .catch((err) => { console.error(err); - error = err.detail; + error = err?.detail ?? err; return null; }); diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 16616fc247..55cda09968 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -18,6 +18,7 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; import Textarea from './common/Textarea.svelte'; + import { normalizeTags } from '$lib/utils/tags'; export let onSubmit: Function = () => {}; export let onDelete: Function = () => {}; @@ -140,10 +141,19 @@ }; const addModelHandler = () => { - if (modelId) { - modelIds = [...modelIds, modelId]; - modelId = ''; + const newModelId = modelId.trim(); + + if (!newModelId) { + return; } + + if (modelIds.includes(newModelId)) { + toast.error($i18n.t('Model ID is already added')); + return; + } + + modelIds = [...modelIds, newModelId]; + modelId = ''; }; const submitHandler = async () => { @@ -240,12 +250,12 @@ : ''; enable = connection.config?.enable ?? true; - tags = connection.config?.tags ?? []; + tags = normalizeTags(connection.config?.tags); prefixId = connection.config?.prefix_id ?? ''; passthroughParams = Array.isArray(connection.config?.passthrough_params) ? connection.config.passthrough_params.join(', ') : (connection.config?.passthrough_params ?? ''); - modelIds = connection.config?.model_ids ?? []; + modelIds = [...new Set(connection.config?.model_ids ?? [])]; if (ollama) { connectionType = connection.config?.connection_type ?? 'local'; @@ -597,6 +607,7 @@ + diff --git a/src/lib/components/AddTerminalServerModal.svelte b/src/lib/components/AddTerminalServerModal.svelte index 0cf622b86f..15be4244e8 100644 --- a/src/lib/components/AddTerminalServerModal.svelte +++ b/src/lib/components/AddTerminalServerModal.svelte @@ -36,7 +36,11 @@ let auth_type = 'bearer'; let path = '/openapi.json'; let enabled = false; + let chatUploads: 'default' | 'filesystem' = 'default'; + let chatContextMode: 'default' | 'chat_id' | 'off' = 'default'; + let automationContextMode: 'default' | 'automation_id' | 'off' = 'default'; let showAdvanced = false; + let showOrchestratorAdvanced = false; let showAccessControlModal = false; let showDeleteConfirmDialog = false; let accessGrants: any[] = []; @@ -77,11 +81,25 @@ auth_type = connection?.auth_type ?? 'bearer'; path = connection?.path ?? '/openapi.json'; enabled = connection?.enabled ?? true; + chatUploads = connection?.config?.chat_uploads === 'filesystem' ? 'filesystem' : 'default'; accessGrants = connection?.config?.access_grants ?? []; // Restore policy state serverType = connection?.server_type ?? (connection?.policy_id ? 'orchestrator' : null); policyId = connection?.policy_id ?? ''; + const contexts = serverType === 'orchestrator' ? (connection?.config?.contexts ?? {}) : {}; + chatContextMode = + contexts?.chat === false + ? 'off' + : contexts?.chat?.context_id === 'chat_id' + ? 'chat_id' + : 'default'; + automationContextMode = + contexts?.automation === false + ? 'off' + : contexts?.automation?.context_id === 'automation_id' + ? 'automation_id' + : 'default'; const p: Record = {}; policyImage = p.image ?? ''; @@ -109,7 +127,10 @@ auth_type = 'bearer'; path = '/openapi.json'; enabled = false; + chatUploads = 'default'; accessGrants = []; + chatContextMode = 'default'; + automationContextMode = 'default'; serverType = null; policyId = ''; @@ -347,6 +368,24 @@ } } + const contexts: Record = {}; + if (chatContextMode === 'off') contexts.chat = false; + else if (chatContextMode === 'chat_id') contexts.chat = { context_id: 'chat_id' }; + if (automationContextMode === 'off') contexts.automation = false; + else if (automationContextMode === 'automation_id') { + contexts.automation = { context_id: 'automation_id' }; + } + const useContexts = + !direct && serverType === 'orchestrator' && Object.keys(contexts).length > 0; + const connectionConfig: Record = + connection?.config && typeof connection.config === 'object' ? { ...connection.config } : {}; + if (!direct) connectionConfig.access_grants = accessGrants; + else delete connectionConfig.access_grants; + if (useContexts) connectionConfig.contexts = contexts; + else delete connectionConfig.contexts; + if (chatUploads === 'filesystem') connectionConfig.chat_uploads = 'filesystem'; + else delete connectionConfig.chat_uploads; + const result = { ...(!direct && id.trim() ? { id: id.trim() } : {}), url, @@ -355,9 +394,7 @@ path, auth_type, enabled: enabled, - config: { - ...(!direct ? { access_grants: accessGrants } : {}) - }, + config: connectionConfig, // Policy fields ...(serverType ? { server_type: serverType } : {}), ...(serverType === 'orchestrator' && policyId ? { policy_id: policyId } : {}) @@ -504,229 +541,305 @@ +
+
+
+ +
+
+ +
+
+
+ {#if serverType === 'orchestrator' && !direct} -
-
-
-
- {$i18n.t('Policy ID')} -
-
-
- -
-
-
- {#if loadingPolicy} -
{$i18n.t('Loading policy...')}
- {:else if policyLoadError} -
- {$i18n.t('Failed to load policy: {{error}}', { error: policyLoadError })} -
- {/if} + -
-
-
-
- {$i18n.t('Image')} - ({$i18n.t('optional')}) + {#if showOrchestratorAdvanced} +
+
+
+
+ {$i18n.t('Terminal Contexts')} +
-
-
- -
-
-
- -
-
-
-
- {$i18n.t('CPU')} -
-
-
- -
-
-
-
-
- {$i18n.t('Memory')} -
-
-
- -
-
-
- -
-
-
-
- {$i18n.t('Storage')} -
-
-
-
- + + + + + +
- {#if policyStorage === 'persistent'} -
- +
+
+ +
+
+
+
+ {$i18n.t('Policy ID')}
- {/if} +
+
+ +
+
+
+ {#if loadingPolicy} +
{$i18n.t('Loading policy...')}
+ {:else if policyLoadError} +
+ {$i18n.t('Failed to load policy: {{error}}', { error: policyLoadError })} +
+ {/if} + +
+
+
+
+ {$i18n.t('Image')} + ({$i18n.t('optional')}) +
+
+
+ +
-
-
-
- {$i18n.t('Idle Timeout')} - ({$i18n.t('min')}) +
+
+
+
+ {$i18n.t('CPU')} +
+
+
+
-
- +
+
+
+ {$i18n.t('Memory')} +
+
+
+ +
-
- -
-
-
-
- {$i18n.t('Environment Variables')} +
+
+
+
+ {$i18n.t('Storage')} +
+
+
+
+ +
+ {#if policyStorage === 'persistent'} +
+ +
+ {/if}
-
- {#each policyEnvPairs as pair, idx} -
+ +
+
+
+ {$i18n.t('Idle Timeout')} + ({$i18n.t('min')}) +
+
+
- +
+
+
+ + +
+
+
+
+ {$i18n.t('Environment Variables')} +
- {/each} -
-
- -
-
-
-
- {$i18n.t('Lifecycle JSON')} -
+ {#each policyEnvPairs as pair, idx} +
+ + + +
+ {/each}
-
-
-
-
- - +
+
+
+
+ {$i18n.t('Lifecycle JSON')} +
+
+ +
-
- {$i18n.t( - 'Policy changes apply to newly provisioned terminals. Refresh matching terminals to apply them to existing terminals.' - )} + +
+
+ + +
+
+ {$i18n.t( + 'Policy changes apply to newly provisioned terminals. Refresh matching terminals to apply them to existing terminals.' + )} +
+
- -
+ {/if} {/if}
diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 4bb310140e..f5ed25449b 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -17,7 +17,11 @@ import Switch from '$lib/components/common/Switch.svelte'; import Tags from './common/Tags.svelte'; import { getToolServerData } from '$lib/apis'; - import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs'; + import { + verifyToolServerConnection, + registerOAuthClient, + initiateOAuthRedirect + } from '$lib/apis/configs'; import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte'; import AccessButton from '$lib/components/common/AccessButton.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; @@ -73,6 +77,33 @@ 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'; const selectClass = 'bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'; + const oauthAuthTypes = ['oauth_2.1', 'oauth_2.1_static']; + const verifyLabel = () => + oauthAuthTypes.includes(auth_type) + ? $i18n.t('Check OAuth Discovery') + : $i18n.t('Verify Connection'); + const verifySuccessMessage = () => + oauthAuthTypes.includes(auth_type) + ? $i18n.t('OAuth discovery successful') + : $i18n.t('Connection successful'); + + const authorizeOAuthHandler = () => { + if (!id) { + toast.error($i18n.t('Please enter a valid ID')); + return; + } + + if (!edit) { + toast.error($i18n.t('Please save the connection before authorizing OAuth')); + return; + } + + initiateOAuthRedirect({ + id: `server:mcp:${id}`, + serverId: id, + authType: 'mcp' + }); + }; const registerOAuthClientHandler = async () => { if (url === '') { @@ -185,14 +216,21 @@ info: { id, name, - description + description, + ...(oauthAuthTypes.includes(auth_type) + ? { + ...(oauthServerUrl ? { oauth_server_url: oauthServerUrl } : {}), + ...(oauthScope ? { oauth_scope: oauthScope } : {}), + oauth_resource_parameter: oauthResourceParameter + } + : {}) } }).catch((err) => { toast.error($i18n.t('Connection failed')); }); if (res) { - toast.success($i18n.t('Connection successful')); + toast.success(verifySuccessMessage()); console.debug('Connection successful', res); } } @@ -301,11 +339,7 @@ return; } - if ( - type === 'mcp' && - ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) && - !oauthClientInfo - ) { + if (type === 'mcp' && oauthAuthTypes.includes(auth_type) && !oauthClientInfo) { toast.error($i18n.t('Please register the OAuth client')); loading = false; return; @@ -358,7 +392,7 @@ id: id, name: name, description: description, - ...(type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) + ...(type === 'mcp' && oauthAuthTypes.includes(auth_type) ? { ...(oauthScope ? { oauth_scope: oauthScope } : {}), oauth_resource_parameter: oauthResourceParameter @@ -609,16 +643,13 @@ required /> - +
- {#if ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)} + {#if oauthAuthTypes.includes(auth_type)}
+ {#if oauthClientInfo} +
+ + + +
+ {/if} +
{ showAccessControlModal = true; }} + label={$i18n.t('Access Control')} /> {/if}
@@ -865,7 +916,7 @@
{/if} - {#if type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)} + {#if type === 'mcp' && oauthAuthTypes.includes(auth_type)}
diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index 69571d30d1..aff4b15fce 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -93,6 +93,9 @@ class="flex shrink-0 items-start justify-between gap-4 px-4 pb-2.5 pt-3.5 dark:text-white text-black" >
+

{$i18n.t("What's New in")} {$WEBUI_NAME} @@ -151,7 +154,7 @@ class="mt-[0.6em] h-1 w-1 shrink-0 rounded-full bg-gray-300 dark:bg-gray-700" >
{@html DOMPurify.sanitize(entry?.raw)} diff --git a/src/lib/components/NotificationToast.svelte b/src/lib/components/NotificationToast.svelte index bfe2166744..d503f760b8 100644 --- a/src/lib/components/NotificationToast.svelte +++ b/src/lib/components/NotificationToast.svelte @@ -101,7 +101,7 @@
+ favicon
{#if title} -
{title}
+
{title}
{/if}
diff --git a/src/lib/components/OnBoarding.svelte b/src/lib/components/OnBoarding.svelte index f9ec738c81..c761dc1933 100644 --- a/src/lib/components/OnBoarding.svelte +++ b/src/lib/components/OnBoarding.svelte @@ -40,6 +40,9 @@ {#if show}
+
-
+ +
Open WebUI
diff --git a/src/lib/components/admin/Analytics/ChartLine.svelte b/src/lib/components/admin/Analytics/ChartLine.svelte index 87f5baf6c5..12de0eb36f 100644 --- a/src/lib/components/admin/Analytics/ChartLine.svelte +++ b/src/lib/components/admin/Analytics/ChartLine.svelte @@ -88,7 +88,9 @@ : period === 'year' || period === 'all' ? 'M/D/YY' : 'M/D'} -
+
{#each Array(labelCount) as _, i} {@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)} {#if data[idx]} @@ -102,13 +104,13 @@ {#if hovered} {@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)}
-
+
{#if hovered.date?.includes(':')} {dayjs(hovered.date).format('MMM D, h A')} {:else} diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index b1cc9e3a5e..423269a985 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -162,7 +162,9 @@ // Reload when the period, group, or custom range changes. // In custom mode, wait until both dates are set to avoid a half-specified query. - $: if (selectedPeriod === 'custom' ? customStart && customEnd : selectedPeriod) { + $: if (selectedPeriod === 'custom' && !(customStart && customEnd)) { + loading = false; + } else if (selectedPeriod) { // reference customStart/customEnd so this block reruns when they change customStart; customEnd; @@ -482,10 +484,13 @@ alt={model.name} class="size-5 rounded-full object-cover shrink-0" on:error={(e) => { + // LICENSE covers this Open WebUI fallback logo. + // Do not alter, remove, obscure, or replace it except as LICENSE permits: + // https://docs.openwebui.com/license. e.target.src = '/favicon.png'; }} /> - {model.name} + {model.name}
{model.count.toLocaleString()} @@ -593,7 +598,7 @@ e.target.src = '/user.png'; }} /> - {user.name || user.email || user.user_id.substring(0, 8)}
diff --git a/src/lib/components/admin/Evaluations.svelte b/src/lib/components/admin/Evaluations.svelte index ee2930ebbb..5a7bb7965e 100644 --- a/src/lib/components/admin/Evaluations.svelte +++ b/src/lib/components/admin/Evaluations.svelte @@ -117,7 +117,7 @@
-
+
{#if selectedTab === 'leaderboard'} {:else if selectedTab === 'feedback'} diff --git a/src/lib/components/admin/Evaluations/FeedbackMenu.svelte b/src/lib/components/admin/Evaluations/FeedbackMenu.svelte index e473ef52a2..fb14aaa702 100644 --- a/src/lib/components/admin/Evaluations/FeedbackMenu.svelte +++ b/src/lib/components/admin/Evaluations/FeedbackMenu.svelte @@ -18,9 +18,9 @@
- +
- +
+ {/if} +
- + - {#if oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT} + {#if oauthConfig.ENABLE_OAUTH}
- - - - {/if} +
+ + + - - - + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
- {#if oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT} + + + + -
- - - + + + + + + + + + + + + + + {#if oauthConfig.ENABLE_OAUTH_ROLE_MANAGEMENT} +
+ + + + + + + +
-
+ {/if} + + + + + + + {#if oauthConfig.ENABLE_OAUTH_GROUP_MANAGEMENT} + + + + +
+ + + + + + + +
+ {/if} + + + + + + + + + + + + {/if} - - - - - - - - - - - - - {/if} +
{/if}
diff --git a/src/lib/components/admin/Settings/CodeExecution.svelte b/src/lib/components/admin/Settings/CodeExecution.svelte index f0f6251dc8..a76a6c5321 100644 --- a/src/lib/components/admin/Settings/CodeExecution.svelte +++ b/src/lib/components/admin/Settings/CodeExecution.svelte @@ -93,6 +93,9 @@ /> + + = {}; let showAddOpenAIConnectionModal = false; let showAddOllamaConnectionModal = false; + let modelListRefreshing = false; const updateOpenAIHandler = async () => { if (ENABLE_OPENAI_API !== null) { @@ -120,6 +122,19 @@ } }; + const refreshModelListHandler = async () => { + modelListRefreshing = true; + + try { + await models.set(await getModels()); + toast.success($i18n.t('Model list refreshed')); + } catch (error) { + toast.error(`${error}`); + } finally { + modelListRefreshing = false; + } + }; + const addOpenAIConnectionHandler = async (connection: any) => { OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url]; OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key]; @@ -373,13 +388,33 @@ )} let:labelId > - { - updateConnectionsHandler(); - }} - ariaLabelledbyId={labelId} - /> +
+ {#if connectionsConfig.ENABLE_BASE_MODELS_CACHE} + + + + {/if} + + { + updateConnectionsHandler(); + }} + ariaLabelledbyId={labelId} + /> +
{:else} diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 3f2911ff36..2c49b05520 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -17,12 +17,13 @@ updateRAGConfig } from '$lib/apis/retrieval'; - import { reindexKnowledgeFiles } from '$lib/apis/knowledge'; + import { reindexKnowledgeFiles, reindexKnowledgeMetadata } from '$lib/apis/knowledge'; + import { reindexMemoryVectors } from '$lib/apis/memories'; import { deleteAllFiles } from '$lib/apis/files'; import ResetUploadDirConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ResetVectorDBConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; - import ReindexKnowledgeFilesConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; + import ReindexEmbeddingDataConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Switch from '$lib/components/common/Switch.svelte'; @@ -121,26 +122,33 @@ }); updateEmbeddingModelLoading = true; - const res = await updateEmbeddingConfig(localStorage.token, { + const payload: Parameters[1] = { RAG_EMBEDDING_ENGINE: RAG_EMBEDDING_ENGINE, 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: { + RAG_EMBEDDING_CONCURRENT_REQUESTS: RAG_EMBEDDING_CONCURRENT_REQUESTS + }; + + if (RAG_EMBEDDING_ENGINE === 'ollama') { + payload.ollama_config = { key: OllamaKey, url: OllamaUrl - }, - openai_config: { + }; + } else if (RAG_EMBEDDING_ENGINE === 'openai') { + payload.openai_config = { key: OpenAIKey, url: OpenAIUrl - }, - azure_openai_config: { + }; + } else if (RAG_EMBEDDING_ENGINE === 'azure_openai') { + payload.azure_openai_config = { key: AzureOpenAIKey, url: AzureOpenAIUrl, version: AzureOpenAIVersion - } - }).catch(async (error) => { + }; + } + + const res = await updateEmbeddingConfig(localStorage.token, payload).catch(async (error) => { toast.error(`${error}`); await setEmbeddingConfig(); return null; @@ -273,7 +281,7 @@ ? JSON.parse(RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS) : {}, CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES: - RAGConfig.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES.trim() === '' + RAGConfig.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES === null ? undefined : RAGConfig.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES.split(',') .map((mimeType: string) => mimeType.trim()) @@ -299,15 +307,15 @@ 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; + OpenAIKey = embeddingConfig.openai_config.key ?? ''; + OpenAIUrl = embeddingConfig.openai_config.url ?? ''; - OllamaKey = embeddingConfig.ollama_config.key; - OllamaUrl = embeddingConfig.ollama_config.url; + OllamaKey = embeddingConfig.ollama_config.key ?? ''; + OllamaUrl = embeddingConfig.ollama_config.url ?? ''; - AzureOpenAIKey = embeddingConfig.azure_openai_config.key; - AzureOpenAIUrl = embeddingConfig.azure_openai_config.url; - AzureOpenAIVersion = embeddingConfig.azure_openai_config.version; + AzureOpenAIKey = embeddingConfig.azure_openai_config.key ?? ''; + AzureOpenAIUrl = embeddingConfig.azure_openai_config.url ?? ''; + AzureOpenAIVersion = embeddingConfig.azure_openai_config.version ?? ''; } }; onMount(async () => { @@ -334,9 +342,8 @@ : config.EXTERNAL_DOCUMENT_LOADER_HEADERS; config.MINERU_FILE_EXTENSIONS = (config?.MINERU_FILE_EXTENSIONS ?? ['pdf']).join(', '); - config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES = ( - config?.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES ?? [] - ).join(', '); + config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES = + config?.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES?.join(', ') ?? null; config.RAG_TOKENIZER_MODEL = config?.RAG_TOKENIZER_MODEL ?? ''; RAGConfig = config; @@ -371,15 +378,37 @@ }} /> - { - const res = await reindexKnowledgeFiles(localStorage.token).catch((error) => { + const knowledgeRes = await reindexKnowledgeFiles(localStorage.token).catch((error) => { + toast.error(`${error}`); + return null; + }); + if (!knowledgeRes) { + return; + } + + const knowledgeMetadataRes = await reindexKnowledgeMetadata(localStorage.token).catch( + (error) => { + toast.error(`${error}`); + return null; + } + ); + if (!knowledgeMetadataRes) { + return; + } + + const memoryRes = await reindexMemoryVectors(localStorage.token).catch((error) => { toast.error(`${error}`); return null; }); - if (res) { + if (memoryRes) { toast.success($i18n.t('Success')); } }} @@ -624,16 +653,27 @@ {/if} {:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'tika'} - - - +
+ + + + + + + + + +
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling'}
{$i18n.t( - 'After changing the embedding model, reindex the knowledge base for changes to take effect.' + 'After changing the embedding model, reindex knowledge, knowledge search, and memory vectors for changes to take effect.' )}
@@ -1526,8 +1566,10 @@
+ {$i18n.t( 'Event names may change as Open WebUI evolves. Use broad patterns like user.* for integrations that should continue across new related events.' )} diff --git a/src/lib/components/admin/Settings/ExternalKnowledge.svelte b/src/lib/components/admin/Settings/ExternalKnowledge.svelte index c03f9980f5..927b25e67a 100644 --- a/src/lib/components/admin/Settings/ExternalKnowledge.svelte +++ b/src/lib/components/admin/Settings/ExternalKnowledge.svelte @@ -9,6 +9,7 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import Switch from '$lib/components/common/Switch.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte'; import DatabaseSettings from '$lib/components/icons/DatabaseSettings.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; @@ -754,6 +755,9 @@
+ {$i18n.t( 'External vectors must be generated with the same embedding model configured in Open WebUI.' )} @@ -794,11 +798,7 @@
{$i18n.t('External Knowledge Sources')}
- - {$i18n.t('Experimental')} - +
diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index a4f7b30bf6..bb480612f3 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -5,6 +5,7 @@ import { getBackendConfig, getVersionUpdates } from '$lib/apis'; import { getAdminConfig, updateAdminConfig } from '$lib/apis/auths'; import { getBanners, setBanners } from '$lib/apis/configs'; + import InterfaceSettings from '$lib/components/common/InterfaceSettings.svelte'; import SettingsSelect from '$lib/components/common/SettingsSelect.svelte'; import Switch from '$lib/components/common/Switch.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; @@ -33,13 +34,14 @@ }; let adminConfig: any = null; + let defaultInterfaceSettings: Record = {}; + let showUserUiDefaults = false; let banners: Banner[] = []; const inputClass = 'w-full h-7 rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; const textareaClass = 'w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; - const checkForVersionUpdates = async () => { updateAvailable = null; version = await getVersionUpdates(localStorage.token).catch((error) => { @@ -59,7 +61,18 @@ _banners.set(await setBanners(localStorage.token, banners)); }; + const saveDefaultInterfaceSettings = (updated: Record) => { + defaultInterfaceSettings = { ...defaultInterfaceSettings, ...updated }; + }; + + const getDefaultInterfaceSettings = () => { + const value = adminConfig?.DEFAULT_INTERFACE_SETTINGS; + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + }; + const updateHandler = async () => { + adminConfig.DEFAULT_INTERFACE_SETTINGS = defaultInterfaceSettings; + const res = await updateAdminConfig(localStorage.token, adminConfig); await updateBanners(); @@ -75,6 +88,7 @@ onMount(async () => { adminConfig = await getAdminConfig(localStorage.token); + defaultInterfaceSettings = getDefaultInterfaceSettings(); banners = [...$_banners]; }); @@ -141,6 +155,9 @@
{$i18n.t('Help')}
+ {$i18n.t('Discover how to use Open WebUI and seek support from the community.')}
@@ -174,6 +191,9 @@
+
{$i18n.t('License')}
{#if $config?.license_metadata} @@ -219,6 +239,9 @@ description={$i18n.t('Allow users to share chats with the Open WebUI community.')} let:labelId > + +
+
+ + + +
+ + {#if showUserUiDefaults} +
+
+
+ {Object.keys(defaultInterfaceSettings).length} + {$i18n.t('settings configured')} +
+ + {#if Object.keys(defaultInterfaceSettings).length > 0} + + {/if} +
+ +
+ +
+
+ {/if} +
+
diff --git a/src/lib/components/admin/Settings/Images.svelte b/src/lib/components/admin/Settings/Images.svelte index 1eb8421409..88e595571b 100644 --- a/src/lib/components/admin/Settings/Images.svelte +++ b/src/lib/components/admin/Settings/Images.svelte @@ -150,8 +150,9 @@ }); if (res) { + backendConfig.set(await getBackendConfig()); + if (res.ENABLE_IMAGE_GENERATION) { - backendConfig.set(await getBackendConfig()); getModels(); } diff --git a/src/lib/components/admin/Settings/Integrations.svelte b/src/lib/components/admin/Settings/Integrations.svelte index df43d0178c..21d33a2118 100644 --- a/src/lib/components/admin/Settings/Integrations.svelte +++ b/src/lib/components/admin/Settings/Integrations.svelte @@ -1,6 +1,7 @@ + +{#if connections.length > 0} +
{$i18n.t('Model providers')}
+ +
+ + {#each connections as connection} + + {/each} + +
+ +
+ +
+{/if} diff --git a/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte b/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte index 9319cb029d..4566c5c2d9 100644 --- a/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte +++ b/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte @@ -24,6 +24,7 @@ import ModelDeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import SettingsSelect from '$lib/components/common/SettingsSelect.svelte'; + import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte'; let modelUploadInputElement: HTMLInputElement; let showModelDeleteConfirm = false; @@ -938,7 +939,7 @@
-
{$i18n.t('Experimental')}
+
+ +
+ +
+ + + + +
+ + {#if loading} +
+ +
+ {:else if providerModels.length === 0} +
+ {$i18n.t('No models found')} +
+ {:else} +
+ {#each providerModels as model} + {@const modelId = getModelId(model)} + {@const displayName = getDisplayName(model)} + {@const status = getStatus(model)} +
+
+
+ {displayName} +
+ {#if displayName !== modelId} +
{modelId}
+ {/if} +
+ + {status} + +
+
+ +
+ + + + + + + + + {#if supportsDelete} + + + + {/if} +
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/admin/Settings/Models/ManageModelsModal.svelte b/src/lib/components/admin/Settings/Models/ManageModelsModal.svelte index ac68484c42..e7f50c19e2 100644 --- a/src/lib/components/admin/Settings/Models/ManageModelsModal.svelte +++ b/src/lib/components/admin/Settings/Models/ManageModelsModal.svelte @@ -1,38 +1,81 @@ - @@ -64,8 +107,22 @@ {:else if selected !== null}
+ {#if hasOllamaManagement && hasProviderManagement} +
+ + + + +
+ {/if} {#if selected === 'ollama'} + {:else if selected === 'provider'} + {/if}
diff --git a/src/lib/components/admin/Settings/Models/ModelMenu.svelte b/src/lib/components/admin/Settings/Models/ModelMenu.svelte index 19f91dda9d..81c7a9ca15 100644 --- a/src/lib/components/admin/Settings/Models/ModelMenu.svelte +++ b/src/lib/components/admin/Settings/Models/ModelMenu.svelte @@ -18,7 +18,7 @@ import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte'; import LockClosed from '$lib/components/icons/LockClosed.svelte'; - import { config, settings } from '$lib/stores'; + import { config, pinnedModels, settings } from '$lib/stores'; import Link from '$lib/components/icons/Link.svelte'; const i18n = getContext('i18n'); @@ -65,9 +65,9 @@
- + -
-
- {/each} -
- {:else} -
- {$i18n.t('No models selected')} -
- {/if} -
-
diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index 7b69b3aeb8..9c9572aceb 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -46,7 +46,8 @@ 'external', 'yandex', 'youcom', - 'linkup' + 'linkup', + 'openserp' ]; let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'microsoft_web_iq', 'external']; @@ -1010,6 +1011,25 @@ />
+ {:else if webConfig.WEB_SEARCH_ENGINE === 'openserp'} +
+
+
+ {$i18n.t('OpenSERP URL')} +
+ +
+ +
+
+
{/if} {#if webConfig.WEB_SEARCH_ENGINE === 'duckduckgo'} diff --git a/src/lib/components/admin/Users.svelte b/src/lib/components/admin/Users.svelte index e9859f6fa8..feebab8411 100644 --- a/src/lib/components/admin/Users.svelte +++ b/src/lib/components/admin/Users.svelte @@ -122,7 +122,7 @@
-
+
{#if selectedTab === 'overview'} {:else if selectedTab === 'groups'} diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte index a9c0083517..66ea53fb1a 100644 --- a/src/lib/components/admin/Users/Groups.svelte +++ b/src/lib/components/admin/Users/Groups.svelte @@ -59,6 +59,11 @@ adminGroupCount.set(groups.length); }; + /** @param {any} updatedGroup */ + const updateGroup = (updatedGroup) => { + groups = groups.map((group) => (group.id === updatedGroup.id ? updatedGroup : group)); + }; + /** @param {any} group */ const addGroupHandler = async (group) => { const res = await createNewGroup(localStorage.token, group).catch((error) => { @@ -142,7 +147,7 @@ bind:value={sortBy} items={sortItems} placeholder={$i18n.t('Sort')} - triggerClass="relative h-8 shrink-0 flex items-center gap-1 px-1.5 py-1.5 bg-transparent rounded-xl text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100" + triggerClass="relative h-8 shrink-0 flex items-center gap-1 px-1.5 py-1.5 bg-transparent rounded-xl text-[0.8125rem] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100" labelClass="inline-flex h-input outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden" align="end" > @@ -176,19 +181,18 @@ {#if filteredGroups.length !== 0}
- {#each filteredGroups as group, idx} - + {#each filteredGroups as group, idx (group.id)} + {#if idx < filteredGroups.length - 1}
{/if} {/each}
{:else} -
-
-
👥
-
{$i18n.t('No groups found')}
-
+
+
+
{$i18n.t('No groups found')}
+
{$i18n.t('Use groups to organize your users and assign permissions.')}
diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index 61fdf15762..0934c9bed2 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -19,6 +19,7 @@ export let onSubmit: Function = () => {}; export let onDelete: Function = () => {}; + export let onMemberChange: Function = () => {}; export let show = false; export let edit = false; @@ -96,7 +97,7 @@ features: { ...DEFAULT_PERMISSIONS.features, ...loadedPermissions.features }, settings: { ...DEFAULT_PERMISSIONS.settings, ...loadedPermissions.settings } }; - data = group?.data ?? {}; + data = structuredClone(group?.data ?? {}); userCount = group?.member_count ?? 0; } @@ -280,7 +281,7 @@ {:else if selectedTab == 'permissions'} {:else if selectedTab == 'users'} - + {:else if selectedTab == 'preview'} {/if} diff --git a/src/lib/components/admin/Users/Groups/GroupItem.svelte b/src/lib/components/admin/Users/Groups/GroupItem.svelte index d9845d08b4..6b7cba8713 100644 --- a/src/lib/components/admin/Users/Groups/GroupItem.svelte +++ b/src/lib/components/admin/Users/Groups/GroupItem.svelte @@ -20,6 +20,8 @@ export let defaultPermissions = {}; export let setGroups = () => {}; + /** @type {(group: any) => void} */ + export let updateGroup = () => {}; let showEdit = false; $: hasCustomPermissions = Object.keys(group?.permissions ?? {}).length > 0; @@ -64,6 +66,7 @@ tabs={['general', 'permissions', 'users', 'preview']} onSubmit={updateHandler} onDelete={deleteHandler} + onMemberChange={updateGroup} /> {/if} diff --git a/src/lib/components/automations/AutomationMenu.svelte b/src/lib/components/automations/AutomationMenu.svelte index adc13c9047..613be824e3 100644 --- a/src/lib/components/automations/AutomationMenu.svelte +++ b/src/lib/components/automations/AutomationMenu.svelte @@ -31,9 +31,9 @@
- + + +
+ + {#if tab === ''} +
+ + + + + +
+ {:else if tab === 'folders'} +
+ + +
+ + +
+ +
+ {#each filteredFolderOptions as folder (folder.id)} + {@const path = folderPath(folder)} + + {:else} +
+ {folderOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No folders')} +
+ {/each} +
+
+ {:else if tab === 'channels'} +
+ + +
+ + +
+ +
+ {#each filteredChannelOptions as channel (channel.id)} + + {:else} +
+ {channelOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No channels')} +
+ {/each} +
+
+ {/if} +
+
+ diff --git a/src/lib/components/automations/FolderDropdown.svelte b/src/lib/components/automations/FolderDropdown.svelte index 0ab80797d7..6e731ba310 100644 --- a/src/lib/components/automations/FolderDropdown.svelte +++ b/src/lib/components/automations/FolderDropdown.svelte @@ -95,7 +95,7 @@ 0}
-
+
{$i18n.t('Folders')}
{/if} @@ -113,7 +113,7 @@ {@const path = folderPath(folder)} {:else} -
+
{folderOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No folders')}
{/each} diff --git a/src/lib/components/automations/ModelDropdown.svelte b/src/lib/components/automations/ModelDropdown.svelte index c4b7310b94..f5522fc5eb 100644 --- a/src/lib/components/automations/ModelDropdown.svelte +++ b/src/lib/components/automations/ModelDropdown.svelte @@ -76,7 +76,7 @@ e.stopPropagation()} @@ -84,13 +84,14 @@
-
+
{$i18n.t('Models')}
{#each filteredModels as model (model.id)}
{:else} -
+
{$i18n.t('No results found')}
{/each} diff --git a/src/lib/components/automations/ScheduleDropdown.svelte b/src/lib/components/automations/ScheduleDropdown.svelte index 14dc3015f2..960ca64695 100644 --- a/src/lib/components/automations/ScheduleDropdown.svelte +++ b/src/lib/components/automations/ScheduleDropdown.svelte @@ -194,13 +194,13 @@ slot="content" class="rounded-xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-48 p-0.5" > -
+
{$i18n.t('Schedule')}
-
+
{$i18n.t('Terminal')}
{#each terminalServers as server (server.id)}
-
+
{#each ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as d}
{d}
{/each}
-
+
{#each miniDays as day}
- +