Compare commits

..

No commits in common. "main" and "v0.9.6" have entirely different histories.
main ... v0.9.6

807 changed files with 57465 additions and 156395 deletions

View file

@ -13,18 +13,6 @@ OPENAI_API_KEY=''
# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080'
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
# For production you should set this to match the proxy configuration (127.0.0.1)
FORWARDED_ALLOW_IPS='*'

2
.github/FUNDING.yml vendored
View file

@ -1 +1 @@
github: open-webui
github: tjbck

View file

@ -2,6 +2,7 @@ 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:
@ -34,8 +35,6 @@ 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
@ -56,7 +55,7 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version (e.g., v0.11.0)
description: Specify the version (e.g., v0.6.26)
validations:
required: true
@ -64,7 +63,7 @@ body:
id: ollama-version
attributes:
label: Ollama Version (if applicable)
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
validations:
required: false
@ -72,7 +71,7 @@ body:
id: operating-system
attributes:
label: Operating System
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
validations:
required: true
@ -80,7 +79,7 @@ body:
id: browser
attributes:
label: Browser (if applicable)
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
validations:
required: false
@ -139,11 +138,11 @@ body:
placeholder: |
Example (include every detail):
1. Start with a clean Ubuntu 26.04 install.
2. Install Docker v29.7.1 and start the service.
1. Start with a clean Ubuntu 22.04 install.
2. Install Docker v24.0.5 and start the service.
3. Clone the Open WebUI repo (git clone ...).
4. Use the Docker Compose file without modifications.
5. Open browser Chrome 151.0 in incognito mode.
5. Open browser Chrome 115.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".

View file

@ -1,5 +1 @@
blank_issues_enabled: false
contact_links:
- name: 🔒 Report a Security Vulnerability
url: https://github.com/open-webui/open-webui/security
about: Do NOT open a public issue for security vulnerabilities, suspected vulnerabilities, or any security-related concern. Please review our Security Policy and report privately via the "Report a vulnerability" button so it can be handled as a private advisory.

View file

@ -36,8 +36,6 @@ 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

View file

@ -1,55 +1,30 @@
<!--
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
2. First-time contributors should not open pull requests directly unless the pull request contains only i18n/localization updates.
Do not open a PR as the first step.
For real, reproducible bugs, start with a well-described Issue 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.
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.
We may close unsolicited PRs without review.
Contributors with a history of successful merged PRs may be given more latitude.
3. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
-->
# Pull Request Checklist
### Do not open a pull request as the first step.
### 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.
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.
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.
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
We appreciate thoughtful contributions. Pull requests are for implementation-ready changes that have already been requested, confirmed, or actively discussed in a linked Issue or Discussion. Feature ideas, behavior changes, UI/UX changes, architecture changes, suspected fixes, and unconfirmed approaches should start as an Issue or Discussion instead.
Before opening a PR, make sure the change has a clear linked problem, follows nearby patterns, has been manually tested, and accounts for related or downstream behavior. PRs that are ideas, prototypes, unresolved design questions, unchecked AI-generated code, symptom-only patches, one-off patches, or changes where affected paths have not been checked will usually be closed.
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
-->
**Before submitting, make sure you've checked and filled out the following:**
**Before submitting, make sure you've checked the following:**
- [ ] **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.
- [ ] **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.
- [ ] **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** 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.
- [ ] **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.
- [ ] **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.
@ -65,6 +40,9 @@ 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
@ -108,7 +86,7 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
### Screenshots or Videos
- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.]
- [Attach relevant screenshots or videos demonstrating the changes]
### Contributor License Agreement

View file

@ -7,10 +7,10 @@ name: Python CI
on:
push:
branches: [main, dev]
paths: ['backend/**', 'pyproject.toml', 'uv.lock', '.github/workflows/backend.yaml']
paths: ['backend/**', 'pyproject.toml', 'uv.lock']
pull_request:
branches: [main, dev]
paths: ['backend/**', 'pyproject.toml', 'uv.lock', '.github/workflows/backend.yaml']
paths: ['backend/**', 'pyproject.toml', 'uv.lock']
concurrency:
group: backend-${{ github.ref }}
@ -38,6 +38,3 @@ jobs:
- name: Verify formatting
run: ruff format --check . --exclude .venv --exclude venv
- name: Detect logic errors
run: ruff check --select=F --ignore=F401,F403,F405,F541,F811,F841 --output-format=github .

View file

@ -75,17 +75,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare CI Dockerfile
run: |
awk '
/^FROM --platform=\$BUILDPLATFORM node:/ {
print
print "ENV NODE_OPTIONS=\"--max-old-space-size=12288\""
next
}
{ print }
' Dockerfile > "${RUNNER_TEMP}/Dockerfile"
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@ -126,7 +115,6 @@ jobs:
id: build
with:
context: .
file: ${{ runner.temp }}/Dockerfile
push: true
platforms: ${{ matrix.platform.arch }}
labels: ${{ steps.meta.outputs.labels }}
@ -243,70 +231,6 @@ jobs:
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
notify-helm-charts:
runs-on: ubuntu-latest
needs: [merge]
if: ${{ !cancelled() && needs.merge.result == 'success' && (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')) }}
steps:
- name: Create Helm charts app token
id: helm-app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HELM_CHARTS_APP_ID }}
private-key: ${{ secrets.HELM_CHARTS_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: helm-charts
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Verify published Open WebUI image
id: image
run: |
set -euo pipefail
image_name="ghcr.io/${GITHUB_REPOSITORY,,}"
ref_name="${GITHUB_REF_NAME}"
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
image_tag="dev"
else
image_tag="${ref_name#v}"
fi
docker buildx imagetools inspect "${image_name}:${image_tag}"
echo "tag=${image_tag}" >> "${GITHUB_OUTPUT}"
- name: Dispatch Helm chart automation
uses: actions/github-script@v8
with:
github-token: ${{ steps.helm-app-token.outputs.token }}
script: |
const isDev = context.ref === 'refs/heads/dev';
const eventType = isDev
? 'open-webui-dev-image-published'
: 'open-webui-release-published';
const refName = context.ref.replace('refs/heads/', '').replace('refs/tags/', '');
const appVersion = refName.startsWith('v') ? refName.slice(1) : refName;
const payload = {
image_tag: isDev ? 'dev' : appVersion,
source_ref: context.ref,
source_sha: context.sha,
source_run_id: String(context.runId),
source_repository: context.repo.repo,
};
if (!isDev) {
payload.app_version = appVersion;
}
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: 'helm-charts',
event_type: eventType,
client_payload: payload,
});
copy-to-dockerhub:
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}

View file

@ -43,8 +43,6 @@ jobs:
- name: Production build
run: npm run build
env:
NODE_OPTIONS: --max-old-space-size=8192
# ── Vitest unit tests ────────────────────────────────────────────────────
unit-tests:

View file

@ -1,139 +0,0 @@
name: Issue Labeler
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
label-bug-reports:
runs-on: ubuntu-latest
steps:
- name: Add "bug" label to unlabeled bug reports
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
// Web-form submissions already carry the label from the issue template
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 ?? '';
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
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) {
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 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']
});

1
.gitignore vendored
View file

@ -310,4 +310,3 @@ dist
cypress/videos
cypress/screenshots
.vscode/settings.json
.cptr

View file

@ -5,873 +5,6 @@ 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
- 🎨 **Redesigned interface.** Open WebUI has been visually rebuilt from the ground up. All aspects of the User Interface, from the chat view to the admin panel. Now with a narrower conversation column, lighter typography, tidier spacing, consistent menus and dropdowns, clearly outlined text boxes, and settings rearranged. [Commit](https://github.com/open-webui/open-webui/commit/aedb6bef4e2eb12234c02085a545ff395d96db18), [Commit](https://github.com/open-webui/open-webui/commit/b3255a36569f295766271b8a2b0bd969b4083b9f), [Commit](https://github.com/open-webui/open-webui/commit/ba067258dea2229a9956077b3b0d7b1c68b56f66), [Commit](https://github.com/open-webui/open-webui/commit/8dd862d3383978f21111e63fb2d6029711abed9a), [Commit](https://github.com/open-webui/open-webui/commit/263bbc77d803e83b9af4b04c0cae29705af5f072), [Commit](https://github.com/open-webui/open-webui/commit/f8ea15b84a274712dca33daa970f63ed7368043e), [Commit](https://github.com/open-webui/open-webui/commit/9f17c5960a0e47a09773da4bba12997a31222fc8), [Commit](https://github.com/open-webui/open-webui/commit/6772b1cb4f4e0d3dc166956014e6e7b9bddc721a), [Commit](https://github.com/open-webui/open-webui/commit/d3fd860c131846a9458888f9c256a9a29f3767f2), [Commit](https://github.com/open-webui/open-webui/commit/f1584b5a3764f72de2de6caad507e7c39ad19c23), [Commit](https://github.com/open-webui/open-webui/commit/2e8d92c7b1a9bb8d35f4a27ba3c73368d735c480), [Commit](https://github.com/open-webui/open-webui/commit/e58a4633b15ae53d33fc3b46cb97c76d86be325f), [Commit](https://github.com/open-webui/open-webui/commit/04b146f2cec7e6a01e9d3590eb83655c128fa3c7), [Commit](https://github.com/open-webui/open-webui/commit/e5e2cd78769639b2df83776f1b991966f922f8b4), [Commit](https://github.com/open-webui/open-webui/commit/3316ba76aabe5429596ffd130fd36be4d5c3aa6c), [Commit](https://github.com/open-webui/open-webui/commit/6fcb38fe2e0aded9b85f655cd8f3279e9f4e765e), [Commit](https://github.com/open-webui/open-webui/commit/421da674468f638f72cc5266c5a3874aa3bca3b7), [Commit](https://github.com/open-webui/open-webui/commit/d0bea60581eaa07d41f92ad8f86007e83247e061), [Commit](https://github.com/open-webui/open-webui/commit/21e180182a5096481d4cbb1a8f94212c0a515a40), [Commit](https://github.com/open-webui/open-webui/commit/d027a32ed134ae104f2f142ba45ff38e56215c5f), [Commit](https://github.com/open-webui/open-webui/commit/437c06c4795a72700295d7690d5fd65d1153372c), [Commit](https://github.com/open-webui/open-webui/commit/1bf05ebc7d135d74969438824778c9f73243ba8d), [Commit](https://github.com/open-webui/open-webui/commit/fd07e3a8e3e619f3712067765b416f0925fa80d3), [Commit](https://github.com/open-webui/open-webui/commit/d3ea51fd466a8741afc4dfd4f0d0f2f77fb6467f), [Commit](https://github.com/open-webui/open-webui/commit/4da2ff2655d9abb851805da127cf60b4d9ad1aa7), [Commit](https://github.com/open-webui/open-webui/commit/2fcb36267f034f2b83f936bfacedc20b680a2710), [Commit](https://github.com/open-webui/open-webui/commit/1428a4ddce4998cb3664a5ce37e176442dd426fa), [Commit](https://github.com/open-webui/open-webui/commit/bc8d24c951e9a2c973fc2dd1f2832a2b0855bc0e), [Commit](https://github.com/open-webui/open-webui/commit/704d07e9a20a830aad7bfc5131b0d92621cf0691), [Commit](https://github.com/open-webui/open-webui/commit/e88d2e053c2a63cce3823c9b6006f4a184c4fef2), [Commit](https://github.com/open-webui/open-webui/commit/6940297486d4a5de127efd1a5148b0adcebe87e3), [Commit](https://github.com/open-webui/open-webui/commit/9ca8cf528af1c49da3f0a2bc3c6ca95c1dedbcf5), [Commit](https://github.com/open-webui/open-webui/commit/c4efa81d08c425678c810c51b4d62716e1e57117), [Commit](https://github.com/open-webui/open-webui/commit/bb12b1a18b77d80829cedb2d5bf965808222415b), [Commit](https://github.com/open-webui/open-webui/commit/49abfbdd155dc22882fdcb09989e4f4964db16ee), [#27178](https://github.com/open-webui/open-webui/pull/27178), [Commit](https://github.com/open-webui/open-webui/commit/dcc7fb1e8ef144205531829f8a56e52171c4d63d), [Commit](https://github.com/open-webui/open-webui/commit/5c505c1119fec6170c1bc092ed162f86262a887e)
- 🤖 **Sub-agents.** Administrators can now enable sub-agents, which let a model hand parts of a task to background helper agents that run their own tool-driven conversations and report results back into the chat, tuned through new "ENABLE_SUBAGENTS", concurrency, iteration, and system-prompt settings. [Commit](https://github.com/open-webui/open-webui/commit/7088d245bb45fc69c0b22748563b9f3c6f0daa73), [Commit](https://github.com/open-webui/open-webui/commit/2f37e853d1259a901f736a823bad29dcc2c3b130), [Commit](https://github.com/open-webui/open-webui/commit/959558fd82eb2a3c980231acd500b73ba4b698b3), [Commit](https://github.com/open-webui/open-webui/commit/3005b7bc71fcbd5abc6e73c3e4caa4ea781cdb76)
- 📂 **Folder pages.** Opening a folder now takes you to its own page, where its chats load a page at a time, can be sorted by title or last updated, and you can start a new chat straight from the folder. [Commit](https://github.com/open-webui/open-webui/commit/409fb39717be9ab7becd9e8c01801a08c5bae318)
- ⏲️ **Chat timers.** The assistant can now set a timer that brings a prompt back into the conversation later, after a delay or at a set time, and can drop it automatically if you read the chat or reply before it fires. [Commit](https://github.com/open-webui/open-webui/commit/b23ddeb2800098c6352203ec8fbe9fca40ba415c)
- 🔔 **Notification targets.** Notifications now have their own settings tab where you can send them to several webhook destinations, each picking which events it wants, from chats finishing or failing to channel messages and calendar alerts, with a test button and a choice between always notifying or only when you are away, and any webhook you already had is carried over for you. [Commit](https://github.com/open-webui/open-webui/commit/c55e373b994d3a14c99a97f44261422012f63266), [Commit](https://github.com/open-webui/open-webui/commit/cf235738f5a44db415012b3b0ebc1f6e752f5439), [Commit](https://github.com/open-webui/open-webui/commit/200d447f6289faca42f2a666bbabae2c7f3ebadf), [#24750](https://github.com/open-webui/open-webui/issues/24750)
- 🗯️ **Full replies in channels.** A reply from the assistant in a channel is now saved and shown in full, with its reasoning, tool calls and other structured parts, where it previously came through blank. [Commit](https://github.com/open-webui/open-webui/commit/498cdab9a548d7d2fd19c389204ee26236fc7efe), [#26720](https://github.com/open-webui/open-webui/pull/26720), [#27409](https://github.com/open-webui/open-webui/pull/27409), [#26707](https://github.com/open-webui/open-webui/issues/26707), [#26656](https://github.com/open-webui/open-webui/issues/26656)
- 📣 **Notifications from the assistant.** The assistant can now send you a notification itself when something is worth your attention, so a long task can reach you after you have moved on to something else. [Commit](https://github.com/open-webui/open-webui/commit/c55e373b994d3a14c99a97f44261422012f63266), [Commit](https://github.com/open-webui/open-webui/commit/200d447f6289faca42f2a666bbabae2c7f3ebadf)
- 🌎 **Share a chat with anyone holding the link.** A shared chat can now be set to Open so it opens without signing in, with visitors no longer bounced to the sign-in page on their way to it, which administrators must first allow through a new "Chats Open Sharing" permission that stays off by default, and such pages ask search engines not to index them. [Commit](https://github.com/open-webui/open-webui/commit/1f0dc90abe879a55f654f2333e29fb0f630831c7), [Commit](https://github.com/open-webui/open-webui/commit/0e0d08382ac0d05b1ad98c47e8a2e37df2a185bb)
- 🔖 **Chat variables.** A model's system prompt can now declare fields such as text boxes and dropdown lists that you fill in for a conversation, with the values saved alongside the chat and carried over when it is forked or cloned. [Commit](https://github.com/open-webui/open-webui/commit/bef8ae4b2f05ca49ed88a02ab7a3cdc11b62c4f1), [Commit](https://github.com/open-webui/open-webui/commit/4e869011cd5040b5d6a197fc83d5f50d2425dbc2), [Commit](https://github.com/open-webui/open-webui/commit/1e88367cc837b39c0e9958fefbe053803336dce2), [Commit](https://github.com/open-webui/open-webui/commit/8cbb7f765cfc9c9b3237a6c5593cd93f849033f0), [Commit](https://github.com/open-webui/open-webui/commit/b35e2d265a4e4a2f2a075b31917d48be1dd9ef19), [Commit](https://github.com/open-webui/open-webui/commit/239cb740077a14e452ad002a1e671a09ab558e40), [#26915](https://github.com/open-webui/open-webui/discussions/26915)
- 🗄️ **LDAP group synchronization.** Administrators can now map LDAP groups to Open WebUI groups from the authentication settings, with optional automatic creation of missing groups, so a user's group memberships are kept in step with the directory each time they sign in. [#27263](https://github.com/open-webui/open-webui/pull/27263), [#18015](https://github.com/open-webui/open-webui/issues/18015)
- 👥 **Restrict sharing with groups.** Admins can now stop resources from being shared with entire groups through a new "USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS" permission, which stays enabled by default so existing group sharing keeps working untouched. [Commit](https://github.com/open-webui/open-webui/commit/4ed19d504bd30c0fc801e9228d9816669ec1c09c), [Commit](https://github.com/open-webui/open-webui/commit/f84dabe3d97ff701c097055023f28f3f2f7ebd07), [Commit](https://github.com/open-webui/open-webui/commit/77da3d8c81b9a6fda4354619de94f5d433328d8e), [Commit](https://github.com/open-webui/open-webui/commit/84e4d6ef8277f4b4f3ac4d355b3219e9b5a37268), [#27124](https://github.com/open-webui/open-webui/pull/27124)
- 🤝 **Shared folder collaboration.** People with access to a shared folder can now use its files and system prompt as knowledge in chat and, with write access, rename and manage the folder, all according to their read or write permission. [Commit](https://github.com/open-webui/open-webui/commit/797293c74957bd79e42262d1dc0fd637a45d0357), [Commit](https://github.com/open-webui/open-webui/commit/caa2457c17e592587b804f21054cc000944af75c), [Commit](https://github.com/open-webui/open-webui/commit/009715cd63d1c8b5320aba68e9a70afcde519016), [Commit](https://github.com/open-webui/open-webui/commit/53ccd718a53de25bb6d61476a6617bfb3f130a44)
- 👁️ **Chat previews in the sidebar.** Hovering a chat in the sidebar now shows a compact preview of its recent messages, so you can find the conversation you want without opening it. [Commit](https://github.com/open-webui/open-webui/commit/d0f7da4f45b8831b09b2ab3ec8f91aa354d90ba3), [Commit](https://github.com/open-webui/open-webui/commit/aaf2834db758bfec69408ab4cabcf324965c221c), [Commit](https://github.com/open-webui/open-webui/commit/1513ddaf58fe18029086880461d7cad0649a699c), [Commit](https://github.com/open-webui/open-webui/commit/93bd05271c07c249978d69abf3297fd2841900f9)
- 🕗 **Local message timestamps.** Message timestamps now appear on hover in your device's local date and time format, with the full weekday and date shown in a tooltip. [Commit](https://github.com/open-webui/open-webui/commit/797293c74957bd79e42262d1dc0fd637a45d0357), [Commit](https://github.com/open-webui/open-webui/commit/f84dabe3d97ff701c097055023f28f3f2f7ebd07)
- 📇 **User variables.** You can now store your own values in account settings, such as your role or how you like answers written, and a model's system prompt can insert them wherever they are needed. [Commit](https://github.com/open-webui/open-webui/commit/bd5d7b2e879511882429075d804d9222956f4a1a), [Commit](https://github.com/open-webui/open-webui/commit/212eec408ca320edfa2271e604d10e14b6a9bc1a), [Commit](https://github.com/open-webui/open-webui/commit/793a43d9c48225925929eb312fe2b70d5914d1da)
- 🧺 **Automations that file their chats away.** An automation can now be pointed at one of your folders, from the dialog, the editor or by asking the assistant, so each run lands there instead of loose in your chat list, and the folder is cleared automatically if it is later deleted. [Commit](https://github.com/open-webui/open-webui/commit/f798d05586a140f1a6b51f1e51b2b2a63d079d45), [Commit](https://github.com/open-webui/open-webui/commit/bab71ed08b5af6f4a8ff2daa02792baae9edab03), [Commit](https://github.com/open-webui/open-webui/commit/db5c092299471444c356216d5ef39b382ba1aa1e)
- 🔵 **See what you have not read yet.** Folders in the sidebar now carry a count of chats with something new in them, a folder's own page marks unread chats with a dot, shows a spinner on any still generating, clears the dot as you open one, and keeps itself up to date as replies finish elsewhere, unread chats sort to the top of a folder, and you can mark a single chat unread again mark everything in a folder read, or mark every chat read at once from the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/f798d05586a140f1a6b51f1e51b2b2a63d079d45), [Commit](https://github.com/open-webui/open-webui/commit/f867825bf3b7699bc2bd967ef46b2bb63e48b098), [Commit](https://github.com/open-webui/open-webui/commit/1de36d600f7191c28a98bf4b347b44cf8f1bef43), [Commit](https://github.com/open-webui/open-webui/commit/85c47fb467177ed811ba77dfe62461dfbe8e2548), [Commit](https://github.com/open-webui/open-webui/commit/b7489bbc6c4e376c017edffd8da3c2eb4e6c1c8e), [Commit](https://github.com/open-webui/open-webui/commit/3cd72ee6a8e93dc39a4d4c173117056e25326c8a), [Commit](https://github.com/open-webui/open-webui/commit/6f93ecd4fd77b0d51a5fbbd2fc3fd6d151036a55), [Commit](https://github.com/open-webui/open-webui/commit/e5a08d52208e8b1ed07ff94906e27d174146b1ca), [Commit](https://github.com/open-webui/open-webui/commit/8ddf119570b3c0b04b673d41cb2363370c23939b)
- 🗜️ **Compact a chat on demand.** Typing a compact command in a long conversation now summarizes the earlier turns straight away, instead of waiting for it to happen automatically once the conversation grows past the threshold. [Commit](https://github.com/open-webui/open-webui/commit/7a9928ef172b7c280c377c86cb52957e39340158), [Commit](https://github.com/open-webui/open-webui/commit/75894161e46aafe30a7db4ecc946af60f04495e4)
- 🌿 **Fork a chat.** Every response now has a fork button that copies the conversation up to that point into a new chat which remembers where it branched, so you can carry on down a different path without touching the original. [Commit](https://github.com/open-webui/open-webui/commit/63ada247066dfc51e0e9559366f0cfd9a98db40b), [Commit](https://github.com/open-webui/open-webui/commit/cf887b68ea58bcd1d8b035842c4ea35a5113ed8b), [Commit](https://github.com/open-webui/open-webui/commit/73421c5b42ac5c2ebc5faa520b7ed3fa0e39f10d), [Commit](https://github.com/open-webui/open-webui/commit/e769f9ff4f9fa7b0faebdd4f34ff98fe0dcc300d)
- 📌 **Pin the conversation map.** The chat overview now has a pin control that stops it recentring on the newest message, so you can keep looking at the branch you were reading while a reply comes in. [#25736](https://github.com/open-webui/open-webui/pull/25736)
- 📊 **Chat status at a glance.** The slash menu now shows how full the context window is, and a new status command opens a panel with context usage, queued messages, running tasks, and the chat ID. [Commit](https://github.com/open-webui/open-webui/commit/7a9928ef172b7c280c377c86cb52957e39340158), [Commit](https://github.com/open-webui/open-webui/commit/263bbc77d803e83b9af4b04c0cae29705af5f072)
- 🎹 **Customizable keyboard shortcuts.** Most keyboard shortcuts can now be rebound to key combinations of your choosing in settings, which saves them to your account, warns you when two actions share a combination, and offers a reset to the defaults, with moving to the previous or next chat and opening the controls panel available to bind as well. [Commit](https://github.com/open-webui/open-webui/commit/343eb1d659262cc9d762a2ce49bef25434a03bfa), [Commit](https://github.com/open-webui/open-webui/commit/de681aa543b356d456b83c13ad88c7f9941319e7), [#26624](https://github.com/open-webui/open-webui/pull/26624)
- ⌨️ **Turn keyboard shortcuts off.** A new switch in the keyboard settings disables every configurable shortcut and hides its hint, so combinations that clash with your browser or operating system pass straight through. [#27300](https://github.com/open-webui/open-webui/pull/27300), [#1008](https://github.com/open-webui/open-webui/issues/1008)
- ⌨️ **Skills in slash commands.** Typing a slash in the message input now lists your skills alongside your prompts, grouped under headings and with descriptions on hover, so you can attach a skill without leaving the keyboard. [Commit](https://github.com/open-webui/open-webui/commit/9588c97e64e10d161a9a0ab1ab9ba3fee6cbb94d)
- 📎 **Attach anything with the at menu.** Typing an at sign in the message input now searches your folders, knowledge collections, and individual files as well as your models, and pasting a link offers it as a web page or YouTube attachment. [Commit](https://github.com/open-webui/open-webui/commit/e8b4c7f9e212253267b8c79dc5360894f0d91bec)
- 📝 **Chat with a note.** Chatting with a note now gives you the full chat experience, including model choice, tools and file attachments, alongside suggested prompts, a button to insert a response straight into the note, edits that appear in the note as the assistant makes them, and as many separate conversations per note as you want to keep. [Commit](https://github.com/open-webui/open-webui/commit/423cafd4e75e34b487f3b5d10ec1c506f073b3da), [Commit](https://github.com/open-webui/open-webui/commit/185bca8552ee3f87ea95fdcad32a433924881b9a)
- ↕️ **Sort your lists.** The notes, prompts, models, knowledge, skills, tools and functions lists can now be sorted by title or by when they were last updated, in either direction, by clicking the column headings. [Commit](https://github.com/open-webui/open-webui/commit/30c91e46e5d237bee3c8805bd54749408cc2727a), [Commit](https://github.com/open-webui/open-webui/commit/56f2cb530259df5393ea1ae844bad5cc6b2c810c), [#27457](https://github.com/open-webui/open-webui/pull/27457), [#27456](https://github.com/open-webui/open-webui/discussions/27456)
- 🗒️ **Notes without stored contents.** A note whose contents were never filled in now opens and saves normally instead of failing. [Commit](https://github.com/open-webui/open-webui/commit/6c59ef313fdaac7fa8e9089be758c4651f8b9412)
- 📄 **Note attachments.** Notes now have an upload option in their menu and show attached files above the note itself, where you can open or remove them, instead of only accepting files dropped onto the page. [Commit](https://github.com/open-webui/open-webui/commit/c4c4ab57e33bb4359b51a4c255f4569fa38a5058), [Commit](https://github.com/open-webui/open-webui/commit/0dc93b8ae798ad834e7f3327571452bfdf4c218f)
- 🗂️ **The assistant can search your attachments.** A new Files capability lets the model list the files attached to the chat and search them by meaning or by exact text, and read the parts it needs, rather than having their whole contents pushed into the conversation up front, and knowledge collections or notes attached to a chat are now announced to the model so it can query those the same way. [Commit](https://github.com/open-webui/open-webui/commit/57e60423b9963c4a69fdfda7ae5799efc5583010), [Commit](https://github.com/open-webui/open-webui/commit/55e0801dab8fe5f8bceff7d7c49772676724b8b1), [#26711](https://github.com/open-webui/open-webui/pull/26711), [#27232](https://github.com/open-webui/open-webui/issues/27232), [#26708](https://github.com/open-webui/open-webui/issues/26708)
- 🔎 **Search in the attachment menu.** The attachment menu now lets you search your knowledge bases, notes, files, and chats instead of scrolling to find them, with matching text shown for chats. [Commit](https://github.com/open-webui/open-webui/commit/668f9fe3905fea5fdfccb2b9b308c8f4d7d2f061)
- ⚗️ **Default file upload mode.** You can now choose in settings how attached files are handled by default, rather than picking that on each upload. [#20900](https://github.com/open-webui/open-webui/pull/20900), [#18431](https://github.com/open-webui/open-webui/issues/18431)
- ⬇️ **Response auto-scroll toggle.** A new interface setting lets you stop the view following a reply as it is written, so you can read earlier text while generation continues. [Commit](https://github.com/open-webui/open-webui/commit/cea991260f279f004489dab5d930bc25b0abf612), [#26826](https://github.com/open-webui/open-webui/pull/26826)
- 📜 **Client certificates for SearXNG.** Web search can now present a client certificate to a SearXNG instance that requires one, through new "SEARXNG_CLIENT_CERT_FILE" and "SEARXNG_CLIENT_KEY_FILE" settings. [Commit](https://github.com/open-webui/open-webui/commit/def26ce266c2e1d80e31b3ad12099db61a674832), [#26992](https://github.com/open-webui/open-webui/issues/26992)
- 🔭 **OpenSERP web search.** Web search can now run against a self-hosted OpenSERP instance, which returns results from several major search engines without any API key, configured through a new "OPENSERP_BASE_URL" setting. [#27437](https://github.com/open-webui/open-webui/pull/27437), [#27438](https://github.com/open-webui/open-webui/issues/27438)
- 🥇 **Model order as a setting.** Administrators can now set the order models appear in through a new "MODEL_ORDER_LIST" variable, so the arrangement survives a restart on instances that do not persist configuration. [#27420](https://github.com/open-webui/open-webui/pull/27420), [#27206](https://github.com/open-webui/open-webui/issues/27206)
- ⏱️ **Idle cap for streamed replies.** Administrators can now set an "AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT" that ends a streamed reply when the provider stops sending anything for that long, instead of holding the connection open until the overall timeout expires. [Commit](https://github.com/open-webui/open-webui/commit/4a7d4ebadac27d652ec200fa3939f10e9a5c17ed), [Commit](https://github.com/open-webui/open-webui/commit/c727643e05f3597395ee1a60b17117d04f693a18)
- 🖼️ **Media types an extraction engine may handle.** Administrators can now list which image and video types the configured content extraction engine is allowed to process, instead of media being passed to it only when the engine is the external one, so an engine with its own text recognition can take images. [Commit](https://github.com/open-webui/open-webui/commit/db2d24896b0682191a54f41c6b9f0b9d2971637f), [#26940](https://github.com/open-webui/open-webui/pull/26940), [#14768](https://github.com/open-webui/open-webui/issues/14768)
- 🧵 **Where a channel reply lands.** Administrators can now choose whether a reply to a mention posts in a thread under that message or straight into the channel. [Commit](https://github.com/open-webui/open-webui/commit/db2d24896b0682191a54f41c6b9f0b9d2971637f), [#27410](https://github.com/open-webui/open-webui/pull/27410)
- 📚 **Limits for knowledge tools.** Administrators can now set how much a knowledge search or file view may return, how many files one search may scan, and how many matches are reported, and a knowledge command's whole output is now capped so a single call cannot flood the conversation. [Commit](https://github.com/open-webui/open-webui/commit/11e61b69ebd922602edc37ded7b42fdd43bb8456), [#27524](https://github.com/open-webui/open-webui/pull/27524), [#27327](https://github.com/open-webui/open-webui/issues/27327), [#26139](https://github.com/open-webui/open-webui/issues/26139)
- 🎛️ **File streaming chunk size.** Administrators can now tune how large each chunk of a streamed file transfer is through a new "AIOHTTP_FILE_STREAM_CHUNK_SIZE" setting. [Commit](https://github.com/open-webui/open-webui/commit/429f2df50cd2f0ec8d0a1bb4136a46e2f94a4bf5)
- 🪛 **Model for summarizing long chats.** Administrators can now pick a dedicated model to write context compaction summaries, separate from the task model, with the conversation's own model used when none is chosen. [#26806](https://github.com/open-webui/open-webui/pull/26806), [#27051](https://github.com/open-webui/open-webui/issues/27051)
- 📏 **Context compaction token cap.** Administrators can now set a "Token Cap" that limits how high per-model context compaction thresholds are allowed to reach, giving finer control over long-conversation summarization. [Commit](https://github.com/open-webui/open-webui/commit/5c389ad93f0668d4bab717d14bd189b679338ef2), [Commit](https://github.com/open-webui/open-webui/commit/31996a5acfe1458720fa19f1b9fb4da95749b5e6), [Commit](https://github.com/open-webui/open-webui/commit/44c2a27ce0695d8e9c7e72f9a84dc325cb15096a)
- ⚖️ **Retained messages after compaction.** Administrators can now set what share of recent messages survives when a long conversation is summarized, between a tenth and half of it. [Commit](https://github.com/open-webui/open-webui/commit/33cf3fbb7f017ab1b79dce5c5ca4d4e1c3092844), [#27050](https://github.com/open-webui/open-webui/issues/27050)
- 🧠 **Memory as a per-model capability.** Whether a model receives your stored memories is now a switch on the model itself, so it can be left on for everyday assistants and off for ones that should start from nothing. [Commit](https://github.com/open-webui/open-webui/commit/6732852ce6c1a2a445bc001b122c60cf12e0278b), [#26861](https://github.com/open-webui/open-webui/pull/26861), [#18610](https://github.com/open-webui/open-webui/discussions/18610)
- ☑️ **Searchable model pickers.** When editing a model, the Tools, Skills, Knowledge, Voice, Filters and Actions pickers now let you search and toggle items in place, select or clear them all at once, and see what is active at a glance. [Commit](https://github.com/open-webui/open-webui/commit/e355959e9156fd61a1105953d731d007fbb4bae3), [Commit](https://github.com/open-webui/open-webui/commit/e1f96aa20ef80c8b01e3a81039d2cca3001d0ef5), [Commit](https://github.com/open-webui/open-webui/commit/fa889837e9e90c7284def56fac2daed20a3ce699), [Commit](https://github.com/open-webui/open-webui/commit/5424ac58917d2a21f21256b62d1c42f1a8c51c51), [Commit](https://github.com/open-webui/open-webui/commit/ea2e3d0afc76fa99f2af665fd50425dd28d000d5), [Commit](https://github.com/open-webui/open-webui/commit/cda5bdb9d42886dfe74d05907179e3f96097030c), [#26758](https://github.com/open-webui/open-webui/issues/26758)
- 🎚️ **Switch for single sign-on.** OAuth and OIDC now have their own on and off switch in the authentication settings, matching the LDAP one above it, so sign-in through a provider can be turned off without clearing the configuration. [#26988](https://github.com/open-webui/open-webui/pull/26988)
- 🖲️ **One sign-in attempt at a time.** The sign-in, sign-up and LDAP form now disables its buttons while a request is in flight, so a slow response no longer turns repeated clicks or Enter presses into several concurrent attempts. [#27416](https://github.com/open-webui/open-webui/pull/27416), [#27264](https://github.com/open-webui/open-webui/issues/27264)
- 🛂 **Trusted clients for token exchange.** Administrators can now list which OAuth clients may have their tokens exchanged for a session, through a new "OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS" setting, so a token a person obtained by signing in to an unrelated application of the same provider can no longer be turned into a session as that person. [#27546](https://github.com/open-webui/open-webui/pull/27546), [Commit](https://github.com/open-webui/open-webui/commit/b190dcf3caa00dc8b7b9c7312828298d9143f60d), [Commit](https://github.com/open-webui/open-webui/commit/c4332be71e6e9c314e8a13b9d2819a6932561630)
- 🚪 **Throttle for token exchange.** Administrators can now cap how often the OAuth token exchange endpoint may be called from one address through new "OAUTH_TOKEN_EXCHANGE_RATE_LIMIT" and "OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW" settings, which bound automated attempts with leaked or guessed tokens and stay off until set. [Commit](https://github.com/open-webui/open-webui/commit/453b9fb0291c0de8957a2713988c7c53dbcc5465)
- 🔏 **PKCE for every sign-in provider.** The code challenge setting now applies to Google, Microsoft and GitHub sign-in as well as OpenID Connect, so the same protection covers every provider. [Commit](https://github.com/open-webui/open-webui/commit/40320c113637f80e0466e30cae63ab9ac1ba596e), [#27302](https://github.com/open-webui/open-webui/pull/27302)
- 🔤 **Embeddings through the OpenAI-compatible API.** Integrations built on OpenAI client libraries can now create embeddings through the Ollama proxy, so embedding requests go through the same sign-in and model access rules as chat instead of needing direct access to Ollama. [#27332](https://github.com/open-webui/open-webui/pull/27332), [Commit](https://github.com/open-webui/open-webui/commit/9f00b62b3a005b030ffaf638fd1da4c27e3c0586), [#27328](https://github.com/open-webui/open-webui/discussions/27328), [Docs:#1331](https://github.com/open-webui/docs/pull/1331)
- 🎚️ **Passthrough parameters per connection.** Administrators can now list request parameters that a connection should receive untranslated, under a new Advanced section in connection settings, so provider-specific options reach the upstream API intact. [Commit](https://github.com/open-webui/open-webui/commit/bb12b1a18b77d80829cedb2d5bf965808222415b)
- 🅰️ **Anthropic requests passed straight through.** Requests to the Anthropic-compatible API aimed at an Anthropic or LiteLLM connection now reach the provider untouched rather than being translated on the way, and LiteLLM is selectable as a connection type. [Commit](https://github.com/open-webui/open-webui/commit/b81627b2c95aad184a6abf59145ca3b18a32bb2c)
- 💭 **Reasoning in Anthropic responses.** Responses from the Anthropic-compatible API now carry the model's reasoning as thinking blocks, in both streamed and complete responses. [Commit](https://github.com/open-webui/open-webui/commit/bb12b1a18b77d80829cedb2d5bf965808222415b)
- 🧩 **Structured output through the Anthropic-compatible API.** Requests can now ask for a JSON schema or JSON object response and set a reasoning effort, which are carried through to the upstream model. [Commit](https://github.com/open-webui/open-webui/commit/bb12b1a18b77d80829cedb2d5bf965808222415b)
- 🪧 **Group names in forwarded headers.** Custom headers on a connection can now carry the groups a person belongs to, by name or by id, so an upstream service or gateway can apply its own rules per group. [#27236](https://github.com/open-webui/open-webui/pull/27236), [#26834](https://github.com/open-webui/open-webui/issues/26834)
- 🪪 **User identity forwarded to Mistral OCR.** Document extraction through Mistral OCR now carries the requesting user's identity when user info forwarding is enabled, so a gateway in front of it can attribute requests per user like other outbound integrations already do. [#27253](https://github.com/open-webui/open-webui/pull/27253), [#27250](https://github.com/open-webui/open-webui/issues/27250)
- 🔢 **Anthropic token-counting endpoint.** The Anthropic-compatible API now offers a token-counting endpoint, so integrations can check how many input tokens a request will use before sending it. [Commit](https://github.com/open-webui/open-webui/commit/08dacd19da1b0eefd9d274d24ed59ec1e5d5a2de), [Commit](https://github.com/open-webui/open-webui/commit/23062e9fcaace42cf06db33f9533127bbbcd33d9)
- 🖲️ **Terminal instructions read fresh.** The instructions a terminal server provides are now fetched for each request, so changing them on the server takes effect immediately instead of after re-saving the connection or restarting. [#27242](https://github.com/open-webui/open-webui/pull/27242)
- 🖥️ **Live terminal server policies.** Administrators can now read an orchestrator terminal server's current policy and lifecycle settings directly in connection settings rather than relying on a locally cached copy. [Commit](https://github.com/open-webui/open-webui/commit/2f37e853d1259a901f736a823bad29dcc2c3b130), [Commit](https://github.com/open-webui/open-webui/commit/3005b7bc71fcbd5abc6e73c3e4caa4ea781cdb76)
- 🌍 **Model privacy at a glance.** Admins can now make a model public or private straight from its menu in the model list, where each model is marked as public, shared, or private. [Commit](https://github.com/open-webui/open-webui/commit/fb2ea272952ed96b0db5ac59a861637204100cb6)
- 📈 **Personal usage dashboard.** A new Usage tab in settings shows your own activity over time, including a token-activity heatmap, current and longest streaks, lifetime and peak token counts, your longest active chat, and your most used models and tools. [Commit](https://github.com/open-webui/open-webui/commit/af9a315ac30b83241f3df5556d7a2abfcd5d25b0)
- 🧠 **Memories in settings.** Your memories are now listed directly in personalization settings where you can search, add, edit, and remove them, instead of being tucked behind a separate manage dialog. [Commit](https://github.com/open-webui/open-webui/commit/db934a3b4ff16532b48670d7cc75e7048a6db993)
- 💾 **Import notes and automations.** Notes can now be brought in from text and markdown files, and automations can be exported and imported as files, so you can move them between instances. [Commit](https://github.com/open-webui/open-webui/commit/f8350360dfd60ff890b73fe2f39aaf20a52ad28b), [Commit](https://github.com/open-webui/open-webui/commit/2018546a7baeb853bb4d98e2fb2a092ef20aa431)
- 🧮 **Counts in the tabs.** The workspace tabs now show how many models, knowledge bases, prompts, skills, and tools you have, and the admin tabs do the same for users, groups, leaderboard entries, and feedback, so you can see the size of each section without opening it. [Commit](https://github.com/open-webui/open-webui/commit/05e3f713175c1eea43a99f29521eb01700c21d3c), [Commit](https://github.com/open-webui/open-webui/commit/727041da78bcfcb44c0e4c83c4e6a641b7061b90), [Commit](https://github.com/open-webui/open-webui/commit/f8ea15b84a274712dca33daa970f63ed7368043e)
- 🧾 **Group permissions at a glance.** The groups list now shows whether each group uses custom or default permissions, without opening it. [Commit](https://github.com/open-webui/open-webui/commit/ccb1ab7739fbeb77c810036dcac240570034a56a), [Commit](https://github.com/open-webui/open-webui/commit/1d1f60ab440b167b9c6ab8f4011b884caa99f27d)
- 📤 **Streamed file transfers.** Uploading a model, pipeline or audio file now sends it in chunks instead of holding the whole thing in memory, and reading and writing files no longer blocks other requests, so large transfers no longer spike memory or stall the server. [Commit](https://github.com/open-webui/open-webui/commit/429f2df50cd2f0ec8d0a1bb4136a46e2f94a4bf5), [#27351](https://github.com/open-webui/open-webui/pull/27351), [#27349](https://github.com/open-webui/open-webui/issues/27349)
- 🧰 **Built-in tool descriptions built once.** The descriptions handed to the model for the built-in tools are now worked out once at startup rather than rebuilt on every message. [Commit](https://github.com/open-webui/open-webui/commit/d727ee4d1febb2b72d5f6c26668c562eadca54f1), [Commit](https://github.com/open-webui/open-webui/commit/12974c9e4ed97b2d68c4ad129c057ff0e774254e), [#27374](https://github.com/open-webui/open-webui/pull/27374), [#27396](https://github.com/open-webui/open-webui/pull/27396)
- 🪺 **Records read without a double pass.** Loading a model, tool, prompt, skill, note, knowledge base, channel or calendar no longer converts the record twice on the way out. [Commit](https://github.com/open-webui/open-webui/commit/f1409266feb224e74fe023d7459f1e2b5aad0b29), [#27377](https://github.com/open-webui/open-webui/pull/27377)
- 🔧 **Faster tool and knowledge base listings.** Listing tools no longer loads each one's full source, and working out which tools and knowledge bases you can see takes a single check rather than one per item. [#27387](https://github.com/open-webui/open-webui/pull/27387)
- 🧊 **Quicker collection checks on Chroma.** Checking whether a collection exists now asks for that one collection instead of listing them all, which grew slower with every knowledge base and file. [Commit](https://github.com/open-webui/open-webui/commit/48ee357156fd7f567ea13eb5ddba0a25701c0351), [#27394](https://github.com/open-webui/open-webui/pull/27394)
- 🔠 **Tokenizer loaded once.** The tokenizer used to split documents is now kept after first use rather than being loaded again for every file. [Commit](https://github.com/open-webui/open-webui/commit/7e31f64bc81b2264136a85efc7bb86c00f346784), [#27394](https://github.com/open-webui/open-webui/pull/27394)
- 📗 **Faster knowledge base file lists.** Opening a knowledge base now loads just the file names and details instead of the entire extracted text of every document, so large collections appear almost instantly. [#27386](https://github.com/open-webui/open-webui/pull/27386), [#26144](https://github.com/open-webui/open-webui/issues/26144)
- 🗝️ **Faster file access checks.** Working out whether you can open a file no longer scales with how many workspace models and knowledge bases exist, so opening files and listing folder contents stays quick on large instances. [#27383](https://github.com/open-webui/open-webui/pull/27383)
- 🕰️ **Faster automation scheduling.** Working out when an automation that repeats every few minutes or hours runs next is now near instant, instead of taking twenty seconds or more and slowing further each year. [Commit](https://github.com/open-webui/open-webui/commit/b3aead23da6cf8ebeedbd9fa3b97c7ac1a3f54ec), [#26954](https://github.com/open-webui/open-webui/issues/26954)
- 📁 **Faster folder loading.** Your folder list no longer runs a separate lookup for every folder to check where it sits, so it loads in a single pass. [Commit](https://github.com/open-webui/open-webui/commit/9a49b271aaf5d6eeaec24ab974be38a8c68ddd76)
- 🎯 **One round of requests per folder click.** Selecting a folder in the sidebar now fetches the folder, the folder tree, and each expanded folder's chats once instead of two to four times. [#27540](https://github.com/open-webui/open-webui/pull/27540), [#27539](https://github.com/open-webui/open-webui/issues/27539)
- 🎧 **No wasted work when nobody is listening.** Updates for a chat whose tab has been closed, or for requests made through the API, are no longer packaged up only to be discarded, which matters most on long streamed replies. [#27366](https://github.com/open-webui/open-webui/pull/27366), [Commit](https://github.com/open-webui/open-webui/commit/858e9236df1c3d84782e373c22b56cfc312b6db8)
- 📑 **Cheaper audit logging.** With audit logging on, each request is no longer authenticated a second time just to record the log entry, so audited instances carry noticeably less overhead. [#27373](https://github.com/open-webui/open-webui/pull/27373)
- 🪧 **Cheaper tagging after each reply.** Saving the tags generated for a conversation now updates just that field instead of loading, rewriting and re-reading the whole conversation, which cost more the longer the chat. [#27382](https://github.com/open-webui/open-webui/pull/27382)
- ✍️ **Faster saves across the app.** Saving a chat, note, prompt, tool or user setting no longer re-reads the record it just wrote, so writes finish sooner, most noticeably on long conversations. [#27381](https://github.com/open-webui/open-webui/pull/27381), [#27379](https://github.com/open-webui/open-webui/pull/27379), [Commit](https://github.com/open-webui/open-webui/commit/c182a95ffdb87bae3d47d93387ec1a26c97740a2), [Commit](https://github.com/open-webui/open-webui/commit/977c7930623b860949410a73922579daf81705a7)
- 🛢️ **Less database overhead per request.** SQLite installations no longer run a connection check before every database call, and requests that never touch the database skip the bookkeeping that used to run regardless. [#27385](https://github.com/open-webui/open-webui/pull/27385)
- ⚡ **Faster memory lookups.** Stored memories are now indexed so retrieving them stays quick as the number you have grows. [Commit](https://github.com/open-webui/open-webui/commit/28bdcb063b8d5d6a0b10943b1b2f87b16ff63621), [#26957](https://github.com/open-webui/open-webui/pull/26957)
- 🪪 **Fewer checks before a reply starts.** Working out whether you may use a model now looks up the model and your group memberships once instead of repeating both, including for every model a workspace model is built on. [#27378](https://github.com/open-webui/open-webui/pull/27378)
- 👤 **Lighter user activity checks.** Checking whether someone is currently active now reads only that timestamp rather than their whole profile, including their profile image. [Commit](https://github.com/open-webui/open-webui/commit/c8f2e09fdcafc800c1e3af6da1cd6f2581cd9191), [#27224](https://github.com/open-webui/open-webui/pull/27224)
- 📨 **Fewer settings lookups when sending a message.** Sending a chat message now reads the settings behind tools, file retrieval, voice, skills and the code interpreter in fewer trips to the database, so replies start sooner. [#27223](https://github.com/open-webui/open-webui/pull/27223)
- 🪄 **Lighter conversion for Ollama requests.** Preparing a request for an Ollama model no longer copies the entire conversation before sending it, which cost more with every message and repeated on each tool-call round. [#27371](https://github.com/open-webui/open-webui/pull/27371)
- 🦙 **Fewer settings lookups on Ollama requests.** Ollama chat, generation and embedding requests now read their connection settings once instead of up to four times, so each request reaches the server sooner. [#27226](https://github.com/open-webui/open-webui/pull/27226)
- 🧹 **Less repeated work on every response.** Security headers are now worked out once at startup rather than rebuilt for each response, and ordinary page requests skip the redirect handling they never needed, so responses carry less overhead. [#27229](https://github.com/open-webui/open-webui/pull/27229)
- 🚀 **Lower per-request overhead.** Requests no longer each perform a settings lookup before they are handled, trimming a little latency from everything the app does. [Commit](https://github.com/open-webui/open-webui/commit/4493b56e424db29fa9e72310b1ca6b025c3e5f8b), [#27395](https://github.com/open-webui/open-webui/pull/27395), [Commit](https://github.com/open-webui/open-webui/commit/6ff1df326c76824f0706671b0974df4035cb453f), [Commit](https://github.com/open-webui/open-webui/commit/85664f650cc111a6b170b97ed0c391d962717bec), [#27227](https://github.com/open-webui/open-webui/pull/27227)
- 💨 **Leaner filter handling while streaming.** Filters applied to a streaming reply no longer re-read their settings and each plugin's full source from the database for every chunk, so responses with filters enabled cost the server far less work. [#27228](https://github.com/open-webui/open-webui/pull/27228), [#27372](https://github.com/open-webui/open-webui/pull/27372), [Commit](https://github.com/open-webui/open-webui/commit/f9107edeebc7ee545d7e3c1f1b7d449c123ab398), [Commit](https://github.com/open-webui/open-webui/commit/f578d8d67ec2c109b0d8c38d90eaeb4448f83610), [Commit](https://github.com/open-webui/open-webui/commit/9acbe3aa0f258a3bda593bb98ec81c6acc458d20), [#27392](https://github.com/open-webui/open-webui/pull/27392)
- 🚦 **No filter bookkeeping without filters.** Streamed API responses only build up the full reply for outlet filters when the model actually has one configured, instead of doing it for every request. [Commit](https://github.com/open-webui/open-webui/commit/315a6b5995663eabe1c96776d66b6593860d47d6), [#27391](https://github.com/open-webui/open-webui/pull/27391)
- ✂️ **Cheaper tag detection while streaming.** Watching a reply for reasoning and code blocks now examines only the newly arrived text rather than rescanning the whole answer on every chunk, so a long answer no longer costs progressively more as it grows. [#27360](https://github.com/open-webui/open-webui/pull/27360)
- 🌊 **Steadier long responses.** Building up a streamed reply no longer costs more work as it grows, so long answers keep pace instead of slowing down toward the end. [#27231](https://github.com/open-webui/open-webui/pull/27231), [#27359](https://github.com/open-webui/open-webui/pull/27359), [Commit](https://github.com/open-webui/open-webui/commit/ba556bd8f0517881cb250bb512631ddf8a0c82c3), [#27390](https://github.com/open-webui/open-webui/pull/27390)
- 📦 **Faster JSON handling as an option.** Administrators can now switch the whole application to a faster encoder through a new "ENABLE_ORJSON" setting, covering request bodies, responses, upstream provider payloads and live updates, where the encoding of live updates was the largest single cost on the workers handling them in clustered deployments; it stays off by default because the faster encoder is stricter about what it accepts. [#27583](https://github.com/open-webui/open-webui/pull/27583)
- ⚙️ **Faster Redis handling.** The compiled "hiredis" parser now ships as a dependency and is used automatically, so deployments backed by Redis spend noticeably less processor time reading responses. [#27282](https://github.com/open-webui/open-webui/pull/27282)
- 🔗 **Fewer Redis round trips per chat.** Deployments backed by Redis now look up the model and connected sessions once per request instead of twice, and fetch the model list in a single call. [#27225](https://github.com/open-webui/open-webui/pull/27225)
- 🛰️ **Fewer Sentinel lookups.** Redis Sentinel deployments no longer ask which server is the primary and open a fresh connection before every single command, which had caused heavy connection churn and stalls under load. [Commit](https://github.com/open-webui/open-webui/commit/75a8a0046b5b2ebd9942b25035b346aa953f81cc), [#27213](https://github.com/open-webui/open-webui/pull/27213), [#27210](https://github.com/open-webui/open-webui/issues/27210)
- 📡 **Lighter live connection handling.** Typing indicators, shared document edits and reconnections no longer re-read your account or copy the full participant list each time, and idle sessions are no longer rewritten every few seconds. [Commit](https://github.com/open-webui/open-webui/commit/021c4c7a2e8b5b213f49800ecd331b1b18c2ef99), [#27393](https://github.com/open-webui/open-webui/pull/27393)
- 🏎️ **Faster chat search on PostgreSQL.** Searching chats on PostgreSQL now reads from the message table instead of unpacking each conversation's stored data row by row, so results stay quick as your history grows. [Commit](https://github.com/open-webui/open-webui/commit/cc9a44569ef08b64ff44d15607c43966f362ce75), [#27221](https://github.com/open-webui/open-webui/issues/27221)
- ⚡ **Lighter model lists.** Model lists no longer carry embedded profile images in their data, so they load faster. [Commit](https://github.com/open-webui/open-webui/commit/9281adc5647b7046e3ddcc53ac4b84be7f650221), [Commit](https://github.com/open-webui/open-webui/commit/f3a35507845e4a911c3d278d680a9989bb8d99ad)
- 🏗️ **Fewer queries when building the model list.** Assembling the model list now makes fewer database round trips and no longer fetches every plugin's source code along the way, so it comes together faster. [Commit](https://github.com/open-webui/open-webui/commit/6b655689ccbdf2111620d005a8e6dedb8fb673f8), [#27389](https://github.com/open-webui/open-webui/pull/27389)
- 🪶 **Model lists without knowledge text.** Model lists no longer include the extracted text of files attached to a model as knowledge, so they stay small regardless of how large those knowledge bases are. [Commit](https://github.com/open-webui/open-webui/commit/48625e657ff11161c3588af2747598f102c1a4d1), [#27287](https://github.com/open-webui/open-webui/issues/27287)
- 🔛 **Functions can react to being switched on or off.** Two new events fire just before a function is enabled or disabled, and the function being enabled receives its own event even though it is not active yet, so it can run whatever setup or teardown it needs. [Commit](https://github.com/open-webui/open-webui/commit/94a60b04573acf6423e9c0519997b779f82e0560), [#26754](https://github.com/open-webui/open-webui/pull/26754), [#26748](https://github.com/open-webui/open-webui/discussions/26748)
- 🔛 **Multiple choice settings in plugins.** A tool or function can now offer a setting where you tick several options from a list, fixed or worked out at the time it is shown, instead of asking you to type a comma-separated list of allowed values. [#26884](https://github.com/open-webui/open-webui/pull/26884), [#26848](https://github.com/open-webui/open-webui/issues/26848)
- 🔌 **Disable plugins entirely.** Administrators can now completely turn off the built-in Tools and Functions plugin surfaces through a new "ENABLE_PLUGINS" setting, which hides them across the workspace and admin areas and removes their execution paths. [Commit](https://github.com/open-webui/open-webui/commit/bd6e0b61c2ae073aba9556ae46c345f4749acb84), [Commit](https://github.com/open-webui/open-webui/commit/8e46450acd7ae11a4dee166d19a7c9833d991e79), [Commit](https://github.com/open-webui/open-webui/commit/951f96021a970fbd4837a4ee441565c0cf3d2824), [Commit](https://github.com/open-webui/open-webui/commit/252e6fd855099e1c880f4def18aa09aedbe1733a)
- 🧵 **Lighter chat listings and search.** Building a page of chat search results or a folder listing no longer copies each full conversation to read its title and dates, so those pages come together faster and use far less memory while they are built. [#27388](https://github.com/open-webui/open-webui/pull/27388)
- 📮 **Name lookups off the thread pool.** Looking up a hostname no longer occupies one of the limited threads shared by every other piece of blocking work, so model calls, searches, page fetches and tool calls stop queueing behind each other once a few lookups are slow. [#27440](https://github.com/open-webui/open-webui/pull/27440)
- 🥬 **Faster web page parsing.** Pages pulled in by web search and web retrieval are now read with a faster parser, cutting roughly a tenth off the time spent on a ten result search. [#27439](https://github.com/open-webui/open-webui/pull/27439)
- 🧭 **No pointless lookups when filtering search results.** Filtering web search results against a domain list no longer resolves every result to an address first, which had turned a three second search into half a minute wherever the resolver was slow or a name did not resolve. [Commit](https://github.com/open-webui/open-webui/commit/42ea8a5a2f04b6a57ccb47a61611a62479b60b78), [#26920](https://github.com/open-webui/open-webui/issues/26920)
- 🚄 **Leaner passthrough streaming.** Responses the server only relays now go straight through in whole network reads instead of being split line by line, roughly halving the work spent shuttling a streamed reply on those routes. [#27384](https://github.com/open-webui/open-webui/pull/27384)
- 🧶 **Web page parsing off the critical path.** Reading those pages no longer holds up everything else on the server, so other people's replies, live updates and health checks keep flowing during a search instead of stalling for a second or more. [#27446](https://github.com/open-webui/open-webui/pull/27446)
- 🈶 **Faster uploads of non-English text files.** Working out the encoding of an uploaded text file now samples the part that needs it rather than scanning the whole file, taking a four megabyte Japanese or Chinese document from several seconds down to well under one. [#27445](https://github.com/open-webui/open-webui/pull/27445)
- ♿ **Improved UI accessibility.** Keyboard and screen reader users can now tell which chat in the sidebar is the one being viewed, open reasoning and detail blocks in a response, expand sidebar sections and open a folder without a mouse, sort the admin user list from the keyboard and hear which column it is sorted by, open a dropdown and its submenus with the keyboard, close them again with Escape and land back where they started, hear which value a dropdown is set to rather than only its label, hear what each admin settings switch, group permission toggle, checkbox, API key field and advanced model parameter slider controls, have the message box announced by its placeholder instead of as an unnamed field, press Enter on Cancel in a confirmation dialog without triggering the delete, reach the regenerate control, jump straight past the sidebar to the conversation with a skip link, hear what an icon-only button does across chat, calls, file previews, modals and the admin pages rather than an unlabelled button, placeholder text, section headings, field descriptions, inactive tab labels, timestamps, counters and icons are now readable against their background when High Contrast Mode is on, and sidebar buttons across notes, automations, the playground, and admin pages announce whether they open or close the sidebar. [#27510](https://github.com/open-webui/open-webui/pull/27510), [#27513](https://github.com/open-webui/open-webui/pull/27513), [#27503](https://github.com/open-webui/open-webui/pull/27503), [#27494](https://github.com/open-webui/open-webui/pull/27494), [#27491](https://github.com/open-webui/open-webui/pull/27491), [#27490](https://github.com/open-webui/open-webui/pull/27490), [#27489](https://github.com/open-webui/open-webui/pull/27489), [#27488](https://github.com/open-webui/open-webui/pull/27488), [#27555](https://github.com/open-webui/open-webui/pull/27555), [#27556](https://github.com/open-webui/open-webui/pull/27556), [#27554](https://github.com/open-webui/open-webui/pull/27554), [#27558](https://github.com/open-webui/open-webui/pull/27558), [#27501](https://github.com/open-webui/open-webui/pull/27501), [#27492](https://github.com/open-webui/open-webui/pull/27492), [#27509](https://github.com/open-webui/open-webui/pull/27509), [#27502](https://github.com/open-webui/open-webui/pull/27502), [#26769](https://github.com/open-webui/open-webui/pull/26769), [Commit](https://github.com/open-webui/open-webui/commit/89caa7c849c471561dfd76140d0c3c3ce7a68df8), [Commit](https://github.com/open-webui/open-webui/commit/7801909d27b18331a9a2bc399e1d618ab99ba5bf), [#26768](https://github.com/open-webui/open-webui/pull/26768), [#26770](https://github.com/open-webui/open-webui/pull/26770), [Commit](https://github.com/open-webui/open-webui/commit/421834b2de287b8d5291d4b695ba6ed4528a5e7f), [Commit](https://github.com/open-webui/open-webui/commit/7bfc4bb2c25249d3922acd54d5bc516db52c8519), [Commit](https://github.com/open-webui/open-webui/commit/e8fda1c7a07d1a0f91201ff977b77fd5a43e014a), [#27508](https://github.com/open-webui/open-webui/pull/27508)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Slovenian is now available, and translations for English (UK), Finnish, German, Japanese, Portuguese (Brazil) and Portuguese (Portugal) 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)
- 🔒 **Terminal file preview isolation.** Previewing an HTML file in the system terminal now runs it in an isolated context by default, closing a cross-site scripting hole that could expose your login session or, for privileged accounts, run code on the server. [#26907](https://github.com/open-webui/open-webui/pull/26907)
- ➗ **Malformed maths in a message.** Maths that fails to render is now shown as plain text rather than being placed into the page as markup, closing a way for a crafted formula in a chat, channel or shared conversation to run code in the browser of anyone reading it. [#26718](https://github.com/open-webui/open-webui/pull/26718)
- 🔩 **Updated file upload parsing library.** The library that parses file uploads and form submissions has been updated to a release that addresses a security advisory affecting that parsing path. [#26991](https://github.com/open-webui/open-webui/pull/26991)
- 🛑 **Deactivated accounts lose live access.** Real-time connections now apply the same role check as the rest of the application, so an account moved out of the user or admin role can no longer keep its channels and shared notes open on an existing token. [#27537](https://github.com/open-webui/open-webui/pull/27537)
- 🛅 **Writing into someone else's chat.** Completion and action requests now confirm you own the chat they name before anything is written to it, so a filter or action can no longer be pointed at another person's conversation. [#27486](https://github.com/open-webui/open-webui/pull/27486)
- 🎟️ **Ollama version no longer readable anonymously.** Reading the configured Ollama backend's version now requires signing in, closing a route that let anyone learn the version in use and count how many backends are configured. [#27199](https://github.com/open-webui/open-webui/pull/27199)
- 🔐 **Folder sharing permission.** The folder sharing setting in default and group permissions now saves instead of being silently discarded, so allowing or restricting folder sharing actually takes effect. [#27296](https://github.com/open-webui/open-webui/pull/27296), [#27120](https://github.com/open-webui/open-webui/issues/27120)
- 🔕 **Webhook permission enforcement.** People without permission to use webhooks can no longer save webhook notification destinations to their settings, so the permission is enforced when settings are saved rather than only reflected in the interface. [#27297](https://github.com/open-webui/open-webui/pull/27297), [Commit](https://github.com/open-webui/open-webui/commit/af629177f46fa4595175f914c971c47701d1a676)
- 🛎️ **Stopping someone else's generation.** Deleting a chat now checks who you are before anything is cancelled, so knowing another person's chat id no longer lets you cut off their reply or title generation on a request that is refused anyway. [#27006](https://github.com/open-webui/open-webui/pull/27006)
- 🚥 **Automation limits in chat.** Automations that the assistant creates or reschedules on your behalf now respect the same maximum count and minimum interval as the ones you set up yourself, instead of being able to exceed both. [#27523](https://github.com/open-webui/open-webui/pull/27523), [#27121](https://github.com/open-webui/open-webui/issues/27121)
- ⏲️ **Cancelling someone else's timers.** Marking a chat as read now only clears your own pending timers on it, instead of clearing everyone's, which had let another person's scheduled prompt be silently cancelled without them being told. [#27472](https://github.com/open-webui/open-webui/pull/27472)
- 🗑️ **Deleting a shared folder's subfolders.** Deleting a folder is now limited to its owner or an administrator at every level, so someone with write access to a shared folder can no longer delete a subfolder and take the owner's chats with it. [#27003](https://github.com/open-webui/open-webui/pull/27003)
- 📕 **Tool source shown to people who can only use it.** Opening a tool you were given read access to no longer returns its source code, which read access was never meant to include. [#27005](https://github.com/open-webui/open-webui/pull/27005)
- 🎯 **Model settings in the list endpoint.** Listing models no longer includes each one's parameters and system prompt for people with read access only, matching what opening a single model already returned. [#27004](https://github.com/open-webui/open-webui/pull/27004)
- 🖌️ **Image generation and web search without permission.** Turning on image generation or web search through the older request format now checks your permission first, so someone denied those features can no longer trigger them, and the billing that comes with them, by asking for that format. [#26703](https://github.com/open-webui/open-webui/pull/26703)
- 🎗️ **Terminal single sign-on tokens.** The token forwarded to a terminal server for single sign-on is now taken from your own session on the server rather than from a header the browser supplied, so a caller can no longer send someone else's token in its place. [#26719](https://github.com/open-webui/open-webui/pull/26719)
- 🫗 **Web search results scoped to you.** The temporary collections holding a web search's pages are now tied to the person who ran the search, closing the one place where that scoping was not applied. [#26706](https://github.com/open-webui/open-webui/pull/26706)
- 🧺 **Knowledge base cleanup reaching other collections.** Tidying up a knowledge base now acts only on files and folders that belong to it, so someone with write access to one knowledge base can no longer delete folders or search data belonging to another. [#26722](https://github.com/open-webui/open-webui/pull/26722)
- ⌛ **Searches that could stall the server.** A search pattern inside knowledge base commands now runs under a time budget, so a pattern that would take minutes to evaluate can no longer hold up everyone else on the instance. [#27471](https://github.com/open-webui/open-webui/pull/27471)
- 🚫 **Disabled terminal servers are refused.** A terminal connection an administrator has turned off can no longer be reached by browsing its files, opening a session, or calling its tools, rather than only disappearing from the interface. [Commit](https://github.com/open-webui/open-webui/commit/7537989235675ac84a40bf70c91ec3e16fc0d8db)
- 🧫 **Files attached to a shared folder.** Adding files to a folder is now refused unless the folder's owner can read them, and a folder's files are checked against what its owner can still read before they are used as knowledge in chat, so a collaborator can no longer place files into someone else's folder or keep serving files the owner has since lost access to. [#27464](https://github.com/open-webui/open-webui/pull/27464), [Commit](https://github.com/open-webui/open-webui/commit/56183fcb17142088e2a34d1e35228f013749030c)
- 🧷 **Knowledge claimed by a direct connection.** Files listed as knowledge on a model supplied by the browser for a direct connection are now filtered against your own access before anything is retrieved, so a crafted request can no longer pull in documents you cannot otherwise open. [Commit](https://github.com/open-webui/open-webui/commit/305880f2e2aeb2dda2f4b2a18a20bdcd558f7134), [#26723](https://github.com/open-webui/open-webui/pull/26723)
- 🪜 **Reaching a restricted model through a shared one.** A shared workspace model can no longer be used to reach an underlying model the person could not otherwise use, which previously slipped through when that model had no entry of its own. [#26905](https://github.com/open-webui/open-webui/pull/26905), [#26900](https://github.com/open-webui/open-webui/issues/26900)
- 🖌️ **Shared image checkpoint changes.** Only administrators can now change the instance-wide Automatic1111 checkpoint, so an ordinary image generation request no longer switches the image model for everyone. [#27244](https://github.com/open-webui/open-webui/pull/27244)
- 💬 **Channel message ownership.** Only the author of a channel message, or an administrator, can now edit or delete it, instead of anyone able to post in that channel. [#27197](https://github.com/open-webui/open-webui/pull/27197)
- 🗄️ **Chats shared with an administrator.** An administrator can now open a chat that was deliberately shared with them even when broad admin access to other people's chats is turned off, instead of being refused a chat any other recipient could read. [#27127](https://github.com/open-webui/open-webui/pull/27127)
- 📓 **Notes in folder knowledge are access checked.** Notes attached to a folder are now filtered against your own access before the list reaches the assistant, rather than relying on later checks further along. [#26739](https://github.com/open-webui/open-webui/pull/26739)
- 🧱 **Code interpreter module blocking.** Modules an administrator has blocked for the code interpreter are now actually blocked, and other imports inside interpreter code work again. [#27245](https://github.com/open-webui/open-webui/pull/27245)
- 📉 **Charts in the code interpreter.** Code that draws a chart now runs in the default code interpreter setup, instead of failing with a syntax error unless file persistence was turned on. [#26800](https://github.com/open-webui/open-webui/pull/26800), [#26660](https://github.com/open-webui/open-webui/issues/26660)
- 🎬 **Chat action availability.** Chat actions can no longer be triggered when they are disabled, not assigned to the model in use, or on a model the caller cannot access, matching the actions the interface actually offers. [#27243](https://github.com/open-webui/open-webui/pull/27243)
- 🗨️ **Response text where it was missing.** Assistant replies are no longer stored without their text, so copying, exporting, searching and reusing a conversation return the reply instead of nothing. [Commit](https://github.com/open-webui/open-webui/commit/33cf3fbb7f017ab1b79dce5c5ca4d4e1c3092844), [#26799](https://github.com/open-webui/open-webui/pull/26799), [#26436](https://github.com/open-webui/open-webui/issues/26436)
- 🧪 **Filter edits that survive a reload.** A change a filter makes to a finished response is now saved with the conversation, instead of showing on screen and reverting the next time the chat is opened. [#27414](https://github.com/open-webui/open-webui/pull/27414), [#27017](https://github.com/open-webui/open-webui/issues/27017)
- 📃 **Action functions receive the response text.** Running an action on a response now passes the assistant's text to the function, instead of handing it an empty message. [#26798](https://github.com/open-webui/open-webui/pull/26798), [#26672](https://github.com/open-webui/open-webui/issues/26672)
- 🍎 **Blank messages on Safari.** Assistant responses no longer render as empty in Safari and on iPhone and iPad, where a browser painting bug left on-screen messages unpainted. [#26805](https://github.com/open-webui/open-webui/pull/26805), [#26712](https://github.com/open-webui/open-webui/issues/26712), [#26844](https://github.com/open-webui/open-webui/issues/26844)
- ➡️ **Prompts opened from a link.** A prompt passed in through a link that sends automatically now waits for tool servers to finish loading, so external tools are available on that first message instead of the model reporting it has none. [Commit](https://github.com/open-webui/open-webui/commit/d7513e4ce81ada1936c0c34115f947e115d6f2cf), [#24176](https://github.com/open-webui/open-webui/issues/24176)
- 🪟 **Tool result prompt submission.** Interactive tool result embeds that send a prompt back to the chat work again, showing the confirmation dialog before submitting instead of silently doing nothing. [#26914](https://github.com/open-webui/open-webui/pull/26914), [#26912](https://github.com/open-webui/open-webui/issues/26912)
- 📻 **Live updates in a second tab.** Opening Open WebUI again while already connected now joins the new tab to your event stream, so notifications and chat updates reach every open tab instead of only the first one. [Commit](https://github.com/open-webui/open-webui/commit/d14fddf25405cd58184fdef3d2af012503e4edd8)
- 🔁 **Connection recovery on new chats.** Chats started from the home page now recover automatically after a dropped connection, such as from mobile backgrounding, a VPN or IP change, or waking from sleep, instead of getting stuck loading until a manual refresh. [#26913](https://github.com/open-webui/open-webui/pull/26913), [#26844](https://github.com/open-webui/open-webui/issues/26844)
- 🪫 **Terminal choice cleared on load.** Your selected terminal is no longer dropped while the list of terminals is still loading, so it survives a page refresh. [Commit](https://github.com/open-webui/open-webui/commit/9707d3a5c21d2fedada602f2cfd8a104cdc5e5d1), [Commit](https://github.com/open-webui/open-webui/commit/f59d86a10cb31e48d2dae0033081a09dbbaafc8c), [Commit](https://github.com/open-webui/open-webui/commit/2f2bf38e3481077597b5ad60f57685813e8d71b8), [#26775](https://github.com/open-webui/open-webui/pull/26775), [#26677](https://github.com/open-webui/open-webui/issues/26677)
- 🔌 **Dropped sessions during keepalive.** Live connections no longer break on a routine keepalive check, which had cut the session so that anything the server needed to run in your browser failed afterwards, most visibly the code execution tool reporting the client as disconnected on every run. [#27553](https://github.com/open-webui/open-webui/pull/27553), [#27550](https://github.com/open-webui/open-webui/issues/27550)
- ✂️ **Context compaction turn boundaries.** Long-conversation compaction now summarizes only completed earlier turns instead of sometimes cutting through the middle of a single turn, keeping the current turn's tool calls and results intact. [#27035](https://github.com/open-webui/open-webui/issues/27035), [Commit](https://github.com/open-webui/open-webui/commit/959558fd82eb2a3c980231acd500b73ba4b698b3), [Commit](https://github.com/open-webui/open-webui/commit/17e6496538e5f3147203b7520012a985cab044b7)
- 🪆 **Summaries on a direct connection.** Summarizing a long conversation on a direct connection can now use the configured summary model rather than being limited to the connection's own model. [#26806](https://github.com/open-webui/open-webui/pull/26806)
- 🪟 **System prompt through compaction.** The system message now stays at the front of the conversation when a long chat is summarized, instead of being folded into the summary and lost from that point on. [Commit](https://github.com/open-webui/open-webui/commit/70549c5c8a50315aa3bf909ebedea8cc3e602077), [Commit](https://github.com/open-webui/open-webui/commit/15688686af9dd73ec974e35f96a7ea24294dbe4f), [Commit](https://github.com/open-webui/open-webui/commit/44f4f9dce48f1ad2af0c5f3210fa5aa701bad624), [#26713](https://github.com/open-webui/open-webui/pull/26713), [#26710](https://github.com/open-webui/open-webui/issues/26710)
- 🧷 **Context compaction continuity.** After a compaction, the retained recent messages now stay in the prompt on every following turn instead of disappearing after the first, preserving conversational continuity and prompt caching. [#27037](https://github.com/open-webui/open-webui/issues/27037), [Commit](https://github.com/open-webui/open-webui/commit/0c23466a3e9a1fb7d32875a0614f1ca8e583bc73), [Commit](https://github.com/open-webui/open-webui/commit/f730733bc44eff5812eff0e51ebca0bbcfa1bc6e)
- 🔟 **Context size after tool calls.** The context meter and long-conversation compaction now read the size of the latest request rather than adding up every call in a tool loop, and understand the counts reported by Ollama and llama.cpp as well as the OpenAI-style ones, so compaction no longer fires far below its threshold, or never at all, and the usage shown is no longer inflated. [Commit](https://github.com/open-webui/open-webui/commit/df94268e892cbb66675170a6c78846aef23f6e89), [Commit](https://github.com/open-webui/open-webui/commit/e8f2c123e63c9073c9ae4ee00573144ce6d4b2e9), [#27031](https://github.com/open-webui/open-webui/issues/27031), [#26752](https://github.com/open-webui/open-webui/pull/26752), [#24410](https://github.com/open-webui/open-webui/discussions/24410)
- 💭 **Reasoning that arrives late or empty.** Reasoning sent by a provider after the answer has started is now shown in its proper place above the answer rather than appended after it, and reasoning notes carrying nothing no longer open an empty thinking block. [Commit](https://github.com/open-webui/open-webui/commit/051a1f6c41d1d37e5a12ce2068fc6f8488940591), [#26687](https://github.com/open-webui/open-webui/pull/26687), [#26645](https://github.com/open-webui/open-webui/issues/26645)
- 📐 **System prompt lost during tool calls.** A model's system prompt now stays in place through every round of tool calls, instead of being dropped after the first one and, with memories enabled, replaced by the memory block alone. [#26857](https://github.com/open-webui/open-webui/pull/26857), [#26836](https://github.com/open-webui/open-webui/issues/26836)
- 🪶 **Memories from structured replies.** A reply delivered as structured output is now read when memories are reviewed after a turn, so nothing worth remembering is skipped just because of how the answer arrived. [Commit](https://github.com/open-webui/open-webui/commit/3fe03583a3b240da3cc42364088f22bc3a059487), [#26705](https://github.com/open-webui/open-webui/pull/26705), [#26651](https://github.com/open-webui/open-webui/issues/26651)
- 🎲 **Stable skill ordering.** Skills available to a model are now listed in the same order on every request, instead of shuffling between requests and quietly defeating prompt caching. [Commit](https://github.com/open-webui/open-webui/commit/b9d72741bb2f649cb67942ce7c635ea173f32b70), [#26986](https://github.com/open-webui/open-webui/issues/26986)
- 🛑 **Stopping an answer the moment it starts.** Each answer in a chat now carries its own task identifier from the first event onward, so stopping one immediately after sending no longer misses. [Commit](https://github.com/open-webui/open-webui/commit/aadab2f480a8c17a9265a244d262947da23ddc79)
- ⏸️ **Deleting while a reply is being written.** The delete control is now hidden on messages while a response is generating or a task is running, so a conversation can no longer be left with the finished reply detached from the messages before it. [Commit](https://github.com/open-webui/open-webui/commit/b4d13793a3af2d75aaf33fe9793cbbf278958940), [#26668](https://github.com/open-webui/open-webui/issues/26668)
- 🎁 **Feedback while a download is prepared.** Downloading a file or folder from the terminal now tells you it is being prepared, will not start the same archive twice if you click again, and reports a failure instead of quietly giving up or leaving a preview spinning. [#27421](https://github.com/open-webui/open-webui/pull/27421), [#27055](https://github.com/open-webui/open-webui/issues/27055)
- 📥 **Moving an archived chat into a folder.** Moving an archived chat into a folder now takes it out of the archive so it appears there, and the folder's contents refresh straight away after a move from the menu. [#27485](https://github.com/open-webui/open-webui/pull/27485), [#27484](https://github.com/open-webui/open-webui/issues/27484)
- 📜 **Chats past the first sixty in a folder.** Folder listings now page through every chat instead of stopping at a fixed limit, so older chats no longer appear to vanish from a folder once it grows past sixty. [#26786](https://github.com/open-webui/open-webui/issues/26786), [Commit](https://github.com/open-webui/open-webui/commit/409fb39717be9ab7becd9e8c01801a08c5bae318)
- 📌 **Sidebar highlight follows the open chat.** The sidebar no longer keeps a chat highlighted after you move to another page, so deleting or archiving it there no longer throws you back to a new chat, and cloning no longer leaves two chats looking selected. [#26977](https://github.com/open-webui/open-webui/pull/26977)
- 🔀 **Sidebar ordering during replies.** Background updates such as follow-up suggestions, sources, and status no longer bump a chat to the top of the sidebar or change its last-updated time, and neither does saving a chat's variables or settings, nor the automatic title generation on a new chat. [Commit](https://github.com/open-webui/open-webui/commit/f1ded9409a5523ec27d99635d8b7e7e1a297a4eb), [Commit](https://github.com/open-webui/open-webui/commit/a9617ca2187920734e2be5f90c5f119c09850ff5)
- 🖱️ **One hover preview at a time.** Moving between chats in the sidebar, or between avatars in the admin user list, channel messages and member lists, no longer leaves an earlier preview open behind the new one. [#27549](https://github.com/open-webui/open-webui/pull/27549), [#27548](https://github.com/open-webui/open-webui/issues/27548), [#27578](https://github.com/open-webui/open-webui/pull/27578), [#27577](https://github.com/open-webui/open-webui/issues/27577)
- ✨ **Folder lists no longer flash.** Clicking a folder title in the sidebar no longer empties the chat lists of your expanded folders for a moment before they reappear. [#27535](https://github.com/open-webui/open-webui/pull/27535), [#27533](https://github.com/open-webui/open-webui/issues/27533)
- 🫧 **Flickering sidebar rows.** Moving the pointer across a chat in the sidebar no longer makes its title and timestamp flicker in and out, or draw the timestamp underneath the action buttons. [#27474](https://github.com/open-webui/open-webui/pull/27474), [#27473](https://github.com/open-webui/open-webui/issues/27473)
- ⭐ **Rating scale in multi-model replies.** The rating scale in the feedback panel is no longer cut off when several models answer side by side, so every score can be picked. [#26846](https://github.com/open-webui/open-webui/issues/26846)
- 🧑‍🤝‍🧑 **Duplicate models side by side.** Adding the same model twice in a side-by-side chat now keeps each column's own answer after a reload, instead of every column collapsing onto the first one. [#26980](https://github.com/open-webui/open-webui/pull/26980)
- ⬅️ **Back button after opening admin or workspace.** Going back in the browser now returns you to the page you came from, instead of being pushed forward again to where you just were. [#27478](https://github.com/open-webui/open-webui/pull/27478), [#27477](https://github.com/open-webui/open-webui/issues/27477)
- 🎛️ **Typing a top_k value.** The top_k box in advanced parameters now accepts whole numbers up to its limit and rejects anything else, instead of letting the slider and the box disagree over what is allowed. [Commit](https://github.com/open-webui/open-webui/commit/34920213619eb66467470105cbe3a275c812ccbc), [#26669](https://github.com/open-webui/open-webui/issues/26669)
- 🌙 **Date pickers in dark mode.** The calendar and clock icons on date and time fields are now visible in dark mode, across the calendar, automation schedules, account settings and analytics. [#27275](https://github.com/open-webui/open-webui/pull/27275), [#27274](https://github.com/open-webui/open-webui/issues/27274)
- 🪞 **Settings content stays inside the window.** Long chat titles in Archived Chats now shorten with the full title on hover, and the admin analytics tables and chart no longer stretch past the edge of the settings window. [#27306](https://github.com/open-webui/open-webui/pull/27306), [#27305](https://github.com/open-webui/open-webui/issues/27305), [#27329](https://github.com/open-webui/open-webui/issues/27329)
- 🔗 **Settings links that open in place.** A link to a settings tab now opens it without a page refresh, and the Add Terminal button in the terminal menu goes straight to the Integrations tab instead of flashing the admin panel and doing nothing. [#27552](https://github.com/open-webui/open-webui/pull/27552), [#27551](https://github.com/open-webui/open-webui/issues/27551)
- 🎰 **Model choice on a fresh chat.** Starting a new chat now falls back to your default model when the previous selection is no longer available, instead of leaving the picker empty, while a model named in the link still wins. [Commit](https://github.com/open-webui/open-webui/commit/f91ac068d09eed381e14d35472d80ae4670fe52b), [#26697](https://github.com/open-webui/open-webui/pull/26697)
- 📱 **Model selector on small screens.** The model list now stays fully on screen and sizes itself to the space available, instead of running past the edge or hiding behind the on-screen keyboard on phones. [Commit](https://github.com/open-webui/open-webui/commit/79d3e34eea6dc2828d1945cc2b9fca5d662d825b), [Commit](https://github.com/open-webui/open-webui/commit/e39ff71532651438c32b2aa7ffe2b6068c94e6b2), [Commit](https://github.com/open-webui/open-webui/commit/ea31a3bd61fdf1a72206f9ed5f7252486d554c9b)
- 📲 **Sidebar stays open over the calendar.** Opening the calendar from the account menu on a phone now closes the sidebar, as every other entry in that menu already did. [#26979](https://github.com/open-webui/open-webui/pull/26979)
- 🗓️ **Automation dialog on narrow screens.** The buttons along the bottom of the automation dialog now sit on their own row on a phone, instead of the schedule and model pickers wrapping and pushing Cancel into the middle. [#27027](https://github.com/open-webui/open-webui/pull/27027)
- 📐 **Input menu with keyboard open.** The message input's attachment menu now stays on screen and resizes to fit when the on-screen keyboard is open on mobile, instead of running off the edge. [Commit](https://github.com/open-webui/open-webui/commit/6e5efc1f757c614814ba88bbae6caa3aaddda528)
- 🎈 **Dropdowns that follow their content.** A menu now stays in place as its contents grow or shrink, instead of running past the edge of the screen when a submenu swaps in taller content, and no longer bounces as it opens. [#27460](https://github.com/open-webui/open-webui/pull/27460), [#27458](https://github.com/open-webui/open-webui/issues/27458)
- 🧾 **Attachment menus load once.** Opening a submenu of the attachment menu now requests its list a single time instead of twice. [#27461](https://github.com/open-webui/open-webui/pull/27461), [#27459](https://github.com/open-webui/open-webui/issues/27459)
- 🔦 **Chat search on PostgreSQL.** Searching your chats now finds matches in current conversations on PostgreSQL setups, instead of only matching chats still stored in the older format. [Commit](https://github.com/open-webui/open-webui/commit/cc9a44569ef08b64ff44d15607c43966f362ce75)
- 🧲 **Search quality with prefix-based embedding models.** Memories, knowledge base descriptions and searches against an external vector database now carry the query and content markers your embedding model expects, so results are no longer quietly worse than they should be on models that rely on them. [Commit](https://github.com/open-webui/open-webui/commit/c4f5ac65ee3cd20dd1d507eda04fca21a866910a), [#26958](https://github.com/open-webui/open-webui/pull/26958), [#26353](https://github.com/open-webui/open-webui/issues/26353)
- 🥄 **Counting matches in knowledge base commands.** Piping text into a search inside knowledge base commands now honours the count and filenames-only flags, instead of returning the matching lines regardless. [#26721](https://github.com/open-webui/open-webui/pull/26721), [#26715](https://github.com/open-webui/open-webui/issues/26715)
- 🔍 **Knowledge base file search.** Searching inside knowledge base files now returns matching lines with correct line numbers, and patterns that list alternatives separated by a pipe find matches instead of silently returning none. [#27249](https://github.com/open-webui/open-webui/pull/27249), [Commit](https://github.com/open-webui/open-webui/commit/e18e249d5da3d8fe701a885edc64341cc5dbf813), [Commit](https://github.com/open-webui/open-webui/commit/8d2fee5d4559d030b53575d377a0013e2c67b9fe), [#26795](https://github.com/open-webui/open-webui/pull/26795), [#26781](https://github.com/open-webui/open-webui/issues/26781), [#26744](https://github.com/open-webui/open-webui/issues/26744)
- 🖨️ **PDF text recognition.** The text recognition package is now included again, so the application starts and PDFs with image text extraction enabled upload correctly instead of failing. [#26851](https://github.com/open-webui/open-webui/pull/26851), [#26646](https://github.com/open-webui/open-webui/issues/26646), [#26994](https://github.com/open-webui/open-webui/issues/26994)
- 🧿 **Mistral OCR on a stock install.** Extracting documents with Mistral OCR now works out of the box, instead of failing on a missing name resolution library that the code assumed was present. [#27440](https://github.com/open-webui/open-webui/pull/27440)
- 📧 **Outlook message uploads.** Uploading a .msg email now works, where it previously failed because the package it relied on could not be installed alongside the rest of the application at all. [#26704](https://github.com/open-webui/open-webui/pull/26704), [#26690](https://github.com/open-webui/open-webui/issues/26690)
- 🖇️ **Uploads with PaddleOCR-VL selected.** With PaddleOCR-VL chosen as the document loader, only PDFs and images now go to it and everything else falls back to the usual handling, so text, markdown, spreadsheet and Word files index instead of being rejected. [#27529](https://github.com/open-webui/open-webui/pull/27529), [#24988](https://github.com/open-webui/open-webui/issues/24988), [#26759](https://github.com/open-webui/open-webui/issues/26759)
- 🪙 **Documents containing special tokens.** Splitting text by tokens no longer fails when the content contains reserved marker sequences, so those pages and files can be fetched and added to a knowledge base. [Commit](https://github.com/open-webui/open-webui/commit/33cf3fbb7f017ab1b79dce5c5ca4d4e1c3092844), [#27094](https://github.com/open-webui/open-webui/issues/27094)
- 📚 **Knowledge base upload reliability.** Adding a file directly to a knowledge base now finishes processing and linking the file before reporting success, so uploaded files are reliably searchable. [Commit](https://github.com/open-webui/open-webui/commit/f5b196c060805fd22e1aa1c9f738b60221ef0fd8)
- 🛠️ **Web loader settings from the admin panel.** The web loader picked in admin settings is now actually used, along with its certificate checking, request pacing and proxy settings, so instances that fetch pages through an external loader work again instead of trying to reach the internet directly with whatever was configured at startup. [#26749](https://github.com/open-webui/open-webui/pull/26749), [#26747](https://github.com/open-webui/open-webui/issues/26747), [Commit](https://github.com/open-webui/open-webui/commit/304cbe4569cddbc9e4641186e51bc9e8d5154533), [#27083](https://github.com/open-webui/open-webui/pull/27083), [#27025](https://github.com/open-webui/open-webui/pull/27025), [#27061](https://github.com/open-webui/open-webui/issues/27061)
- 🚧 **Quoted entries in the web fetch filter list.** Stray quote marks around a filter entry, which Docker Compose passes through literally, no longer turn the list into one that blocks every web address. [#26910](https://github.com/open-webui/open-webui/pull/26910), [#26908](https://github.com/open-webui/open-webui/issues/26908)
- 🌐 **Web fetching with certain plugins installed.** Fetching a web page and loading web search results work again on instances where a tool or function pulls in a replacement networking library, which previously made every fetch fail and return nothing. [#26796](https://github.com/open-webui/open-webui/pull/26796), [#26791](https://github.com/open-webui/open-webui/issues/26791)
- 📢 **Web search failures explained.** When a search finds pages but cannot store them, the chat now says what went wrong and points at the document settings, instead of reporting sites searched and then no sources found. [#26883](https://github.com/open-webui/open-webui/pull/26883)
- 🕸️ **Mixed web page extraction.** Fetching several web pages at once now reads each one according to its own format, instead of applying the first page's format to the whole batch and garbling the rest. [#27367](https://github.com/open-webui/open-webui/pull/27367)
- 🧯 **Leftover browser sessions on web fetches.** Fetching pages through a remote Playwright server now closes each page and the browser even when a page times out or the search is abandoned partway, instead of leaving sessions open and slowing every later search until that server was restarted. [#27526](https://github.com/open-webui/open-webui/pull/27526), [#25880](https://github.com/open-webui/open-webui/issues/25880)
- 🖇️ **Sign-in profile pictures fetched safely.** The profile picture pulled in when someone signs in through a provider is now fetched through the same protected path as other outbound requests, so a host that changes its address between the check and the fetch can no longer point it at an internal service, taking the forwarded sign-in token with it. [#26699](https://github.com/open-webui/open-webui/pull/26699)
- 🪃 **Backslashes in terminal proxy paths.** A request to the terminal proxy containing a backslash is now refused, closing a way to smuggle directory traversal past the path check to an upstream that treats it as a separator. [#27198](https://github.com/open-webui/open-webui/pull/27198)
- 🧱 **Internal addresses disguised as public ones.** A web address that hides an internal target inside an IPv6 address, through the mapped, 6to4, Teredo or NAT64 forms, is now recognised and refused like any other internal address. [Commit](https://github.com/open-webui/open-webui/commit/1717b493d83c86afa82aa8bc50139250852dd2f3)
- 🪤 **Tighter checks when a page is fetched.** Every request a fetched page makes is now checked against the address rules rather than only the page itself, each hop of a redirect is checked in turn, and background workers and socket connections the page tries to open are refused. [Commit](https://github.com/open-webui/open-webui/commit/bef63a2ae915571d50d2722a635e8bfa753d7877), [#27042](https://github.com/open-webui/open-webui/pull/27042), [#27008](https://github.com/open-webui/open-webui/pull/27008)
- 🐢 **Dropped pages when fetches are paced.** Pages fetched through Firecrawl, Tavily, Microsoft Web IQ or Playwright are no longer discarded whenever the loader has to pause between requests, which quietly lost any page following close behind another and sometimes blamed it on a failed security check. [#27528](https://github.com/open-webui/open-webui/pull/27528), [#26079](https://github.com/open-webui/open-webui/issues/26079)
- 🎙️ **Dictation repeating earlier speech.** Dictating into the message box no longer re-inserts everything you said in previous recordings, and cancelling a recording no longer inserts the text anyway. [#26793](https://github.com/open-webui/open-webui/pull/26793), [#26784](https://github.com/open-webui/open-webui/issues/26784)
- 🧩 **Order of long transcriptions.** A long recording split into pieces for transcription is now reassembled in the order it was spoken, instead of sections sometimes appearing out of sequence in the transcript and everything read from it. [#27417](https://github.com/open-webui/open-webui/pull/27417), [#27143](https://github.com/open-webui/open-webui/issues/27143)
- 🔊 **Text-to-speech reliability.** Text-to-speech playback and other streamed responses no longer intermittently cut out partway through when several requests run at once. [#26924](https://github.com/open-webui/open-webui/pull/26924), [#26922](https://github.com/open-webui/open-webui/issues/26922)
- 🧮 **Anthropic usage reporting.** Responses from the Anthropic-compatible API now report accurate input and output token counts, pass through cache and server tool figures where the provider gives them, and leave the input count out entirely rather than reporting zero when it is unknown. [Commit](https://github.com/open-webui/open-webui/commit/e8b59b2ef35ecb727fa760cd565d6da20c9e7e79), [Commit](https://github.com/open-webui/open-webui/commit/51ff386fd6461c07225d10a0de530019eebdd157), [Commit](https://github.com/open-webui/open-webui/commit/0576e8eeb5797a36b43eba5790a4e2a5dd8e5a4d), [Commit](https://github.com/open-webui/open-webui/commit/8e74cac8decc0a54137d7214e70c33b0dd52a99a), [Commit](https://github.com/open-webui/open-webui/commit/93a34bb25b32ae0b3a876fd3161c7778227b76bf), [Commit](https://github.com/open-webui/open-webui/commit/4c2d864b3f4c1ae6c4c9bd93aea078d4bf520463), [#26790](https://github.com/open-webui/open-webui/pull/26790), [#27293](https://github.com/open-webui/open-webui/pull/27293), [Docs:#1328](https://github.com/open-webui/docs/issues/1328)
- 📨 **Non-streaming requests to strict providers.** A request that is not streaming no longer carries the streaming-only usage option, which some providers reject outright.
- 🪝 **Tool calls with structured arguments.** A provider that sends a tool call's arguments as an object, or as nothing at all, no longer breaks the reply partway through. [#27195](https://github.com/open-webui/open-webui/issues/27195)
- 🧬 **Shared pipe model tool calls.** Non-admin users of a shared model built on a pipe or manifold model no longer see the response silently stop right after a tool call. [#26906](https://github.com/open-webui/open-webui/pull/26906), [#26900](https://github.com/open-webui/open-webui/issues/26900)
- 🧑‍🔧 **Startup as an arbitrary user.** Running the image as a non-root account, as OpenShift and similar setups do, no longer fills the boot log with permission errors while it writes its own icons and manifest. [#26664](https://github.com/open-webui/open-webui/pull/26664), [#26662](https://github.com/open-webui/open-webui/issues/26662)
- 🩹 **Startup with an ownerless tool or function.** A tool or function left without an owner no longer prevents the application from starting, which had blocked all chat responses until it was removed. [#26850](https://github.com/open-webui/open-webui/pull/26850), [#26843](https://github.com/open-webui/open-webui/issues/26843)
- 🏷️ **Model names containing a connection prefix.** A prefix set on a connection is now removed only from the front of the model name, so a model whose own name contains that text is no longer mangled before the request is sent. [Commit](https://github.com/open-webui/open-webui/commit/ed663f16ecaaf99de194922d1634ecc5d906c703)
- 🦙 **Newly pulled Ollama models.** Sending a message to a model that was pulled after the list was last built now refreshes the list and proceeds, instead of reporting the model as not found. [Commit](https://github.com/open-webui/open-webui/commit/ed663f16ecaaf99de194922d1634ecc5d906c703), [#27353](https://github.com/open-webui/open-webui/pull/27353)
- 🗑️ **Deleting a model from the selector.** Removing a workspace model from the model selector menu now deletes just that model and leaves the underlying one in place, instead of failing with a not found error. [#26819](https://github.com/open-webui/open-webui/pull/26819)
- 🔑 **Connecting a remote MCP server over OAuth.** Setting up a remote MCP server now reports plainly when its sign-in details cannot be discovered, rather than saving an unusable connection that failed with a server error the moment you tried to authorise it. [#26654](https://github.com/open-webui/open-webui/pull/26654), [#26647](https://github.com/open-webui/open-webui/issues/26647)
- 🪢 **Tool servers with cross-referencing types.** A tool server whose description defines types that refer to each other now loads its tools instead of failing outright, so the integration appears in model and tool selection again. [#27413](https://github.com/open-webui/open-webui/pull/27413), [#27239](https://github.com/open-webui/open-webui/issues/27239)
- 👥 **Previewing what someone can use.** The preview of a person's access now includes the models, knowledge bases and tools they own, not just the ones shared with them. [Commit](https://github.com/open-webui/open-webui/commit/a9a3e5b95c8e641881fedc1ce7431eedab9a371b), [#27423](https://github.com/open-webui/open-webui/pull/27423), [#27407](https://github.com/open-webui/open-webui/discussions/27407)
- 🧰 **Model editor loading.** The model editor no longer fails to open when its tool list can't be loaded, falling back gracefully instead. [Commit](https://github.com/open-webui/open-webui/commit/10724d057af13a826c52e92b1c01a031656768d5)
- 🗃️ **Milvus Lite collection creation.** Setting up collections now succeeds on embedded Milvus Lite, which previously could fail while creating the resource index. [#26911](https://github.com/open-webui/open-webui/pull/26911)
- 🧽 **Milvus log noise.** Instances backed by Milvus no longer fill their logs with deprecation warnings while indexing and retrieving, and keep working with future PyMilvus releases that drop the old interface entirely. [#27521](https://github.com/open-webui/open-webui/pull/27521), [#26978](https://github.com/open-webui/open-webui/issues/26978)
- 🚏 **Stray terminal containers.** Terminal orchestrator connections that use a policy now send every request through that policy, so each person no longer ends up with a second unintended container alongside the intended one. [#26945](https://github.com/open-webui/open-webui/issues/26945), [Commit](https://github.com/open-webui/open-webui/commit/7088d245bb45fc69c0b22748563b9f3c6f0daa73)
- 🔦 **Connections on hardened instances.** With the admin access bypass turned off, a connection that has no access grants yet is now reachable by administrators again, instead of being hidden from everyone including the admin who created it. [#27581](https://github.com/open-webui/open-webui/pull/27581), [#27580](https://github.com/open-webui/open-webui/issues/27580), [#27064](https://github.com/open-webui/open-webui/issues/27064)
- ♻️ **Connection changes take effect immediately.** Saving connection settings now refreshes the model list straight away, instead of leaving the previous models in place until the server was restarted.
- 🚫 **Disabled OpenAI connections are enforced.** Turning off the OpenAI API now blocks chat requests to it and clears its models, rather than only hiding it from the interface.
- 🪛 **Deleting an Ollama connection.** Removing an Ollama connection now saves straight away, instead of reappearing until the Ollama API switch was toggled afterwards. [#27483](https://github.com/open-webui/open-webui/pull/27483), [#27482](https://github.com/open-webui/open-webui/issues/27482)
- 🧹 **Orphaned sessions get cleaned up.** The instance that reaps sessions left behind by a crashed worker now keeps trying if another instance holds the job, rather than one instance giving up for good and leaving stale sessions to accumulate, and the lock it uses can no longer be released or renewed by an instance that does not hold it. [Commit](https://github.com/open-webui/open-webui/commit/bf35f64a7f14161933dfa608577a977d107b1569), [Commit](https://github.com/open-webui/open-webui/commit/846ba80a9d5e75837d3db37185e9a23b1e6bfe78)
- 🧊 **Redis cluster connections.** A deployment using Redis in cluster mode is no longer handed a connection built for a single server, or the reverse, when both point at the same address. [Commit](https://github.com/open-webui/open-webui/commit/fc4906c9e9df3fa42bb9073ac197383347caa853)
- 🚏 **Stopping a reply when Redis is configured.** The stop button now actually halts generation on Redis-backed deployments, where the listener that carries stop requests between instances quietly died after a few idle seconds and left tokens streaming on, and a new "REDIS_SOCKET_TIMEOUT" setting controls that timeout. [#27104](https://github.com/open-webui/open-webui/pull/27104), [#26779](https://github.com/open-webui/open-webui/issues/26779)
- 🛟 **Redis failover on timeouts.** A Redis connection that times out now retries against a freshly resolved primary instead of failing, so Sentinel setups recover from a failover rather than erroring out. [Commit](https://github.com/open-webui/open-webui/commit/75a8a0046b5b2ebd9942b25035b346aa953f81cc), [#27210](https://github.com/open-webui/open-webui/issues/27210)
- 👣 **First sign-in through a trusted header.** Two requests arriving together for someone signing in for the first time through a trusted header no longer create two accounts for the same person, and the database now refuses a second account for an address that already exists, whatever its capitalisation. [Commit](https://github.com/open-webui/open-webui/commit/b190dcf3caa00dc8b7b9c7312828298d9143f60d), [Commit](https://github.com/open-webui/open-webui/commit/50e050e1957de40caa9df479b4c0d9b814f1f623), [#27571](https://github.com/open-webui/open-webui/pull/27571), [#27117](https://github.com/open-webui/open-webui/issues/27117)
- 🔧 **Sign-on settings from environment variables.** Single sign-on settings supplied through environment variables are no longer overridden by stale values saved at first startup, so changing them takes effect. [#26928](https://github.com/open-webui/open-webui/pull/26928), [#26917](https://github.com/open-webui/open-webui/issues/26917)
- 🎫 **Expired identity tokens sent to tools.** A sign-in session is now refreshed before the earliest of its tokens expires, so tools and pipes that forward your identity no longer hand a downstream service a token it rejects. [#27520](https://github.com/open-webui/open-webui/pull/27520), [#27066](https://github.com/open-webui/open-webui/issues/27066)
- 🎫 **Sign-in tokens that never expire.** A provider that returns no expiry and no way to refresh is now taken at its word, instead of being given an invented one-hour lifetime that left the session unusable afterwards. [Commit](https://github.com/open-webui/open-webui/commit/98656b7c5e29383b61d2113164466b8d4ab1d424), [#26802](https://github.com/open-webui/open-webui/pull/26802), [#26141](https://github.com/open-webui/open-webui/issues/26141)
- 🔓 **Single sign-on after a key rotation.** Signing in with OIDC now recovers when the provider rotates its signing key, refreshing the cached keys and retrying instead of failing with an invalid credentials error. [#27310](https://github.com/open-webui/open-webui/pull/27310), [#26407](https://github.com/open-webui/open-webui/issues/26407)
- 🔑 **Signing in after a session expires.** An expired session now cleanly returns you to the sign-in page and back to where you were afterwards, instead of bouncing you away from the sign-in page or leaving a stale session behind. [Commit](https://github.com/open-webui/open-webui/commit/609cc6ad9b597c6a3f4df6f9dba93d6ff6ec1f18), [Commit](https://github.com/open-webui/open-webui/commit/29782aba01b8f34625949170dc9ee9e1c5872893), [#26751](https://github.com/open-webui/open-webui/pull/26751), [#26731](https://github.com/open-webui/open-webui/issues/26731)
- 🫥 **Temporary chats and channels write nothing.** Generating or editing an image and status updates in a temporary chat or a channel message no longer try to save themselves against a conversation that was never stored, and the task list tools are no longer offered there at all rather than being offered and then failing. [Commit](https://github.com/open-webui/open-webui/commit/d484a2a99e3a0c21fdcad007a50ebc412fffbb2e), [Commit](https://github.com/open-webui/open-webui/commit/d2936c880cfc8cb71bb5c235926048ae189bba25), [Commit](https://github.com/open-webui/open-webui/commit/b45c020f68a9499b66e598e854b17a3f232b6cf2), [Commit](https://github.com/open-webui/open-webui/commit/71c4da8c065491a96e41da3c9f0c663e5f759468), [#27432](https://github.com/open-webui/open-webui/issues/27432)
- 🎞️ **Artifacts panel reopening itself.** The artifacts panel now opens once when a finished block is detected, so closing it partway through a reply no longer sees it forced back open on every word that follows. [Commit](https://github.com/open-webui/open-webui/commit/4856afcef8251969f751ade5760cefea9577c051), [#27399](https://github.com/open-webui/open-webui/issues/27399)
- 🏞️ **Images returned by a tool.** Images a tool produces are now passed to the model in a form the OpenAI-compatible providers accept, so it can actually look at them instead of receiving a result it cannot read. [Commit](https://github.com/open-webui/open-webui/commit/dd86b984bd508cf2841f08dae90411dfb5fe407f)
- 🖼️ **External message images.** Images hosted on other sites and referenced in a message now display inline instead of being replaced with a placeholder. [Commit](https://github.com/open-webui/open-webui/commit/890bfd0d9771d1919ce24f04e11b6c96589fca5b)
- 🔣 **Names containing a vertical bar.** What you insert with the at sign or a slash is now recorded by the key you typed rather than guessed from its name, so a prompt or model whose name contains a vertical bar is no longer mistaken for a skill. [Commit](https://github.com/open-webui/open-webui/commit/e28b391e514384ec329ca871d02189aa81fb1a00)
- 〰️ **Text above a collapsible block.** A line written directly above a collapsible section is no longer turned into a large heading, and the section itself still renders as a collapsible widget rather than leaking its markup. [Commit](https://github.com/open-webui/open-webui/commit/7d77efe0f1cfa4782893ffde78419a96a95240f4), [#27148](https://github.com/open-webui/open-webui/pull/27148), [#27001](https://github.com/open-webui/open-webui/issues/27001)
- ✳️ **Asterisks in the message input.** Wrapping a word in asterisks no longer silently turns it italic and swallows the asterisks, so your prompt reaches the model exactly as you typed it. [Commit](https://github.com/open-webui/open-webui/commit/001775d8e868ce44f125e0d683dd50363f0e8318)
- 📶 **Reconnect warnings on mobile.** Switching back to Open WebUI after using another app no longer flashes a connection lost warning while the tab wakes up and reconnects on its own. [Commit](https://github.com/open-webui/open-webui/commit/63ada247066dfc51e0e9559366f0cfd9a98db40b)
- 🧭 **Sidebar access from the automation editor.** Opening an automation on a phone no longer hides the sidebar button, so you can move around without leaving the editor first.
- 🔣 **Chats containing unusual characters.** Broken character sequences are now cleaned out of text before it is stored, so a conversation that picked one up still saves and still opens instead of failing to load. [Commit](https://github.com/open-webui/open-webui/commit/43e7eefa959918baf9fbf867a12b5d721fd782af), [#27201](https://github.com/open-webui/open-webui/pull/27201), [#27081](https://github.com/open-webui/open-webui/issues/27081)
- 📛 **Failures after a tool call.** A reply that fails while continuing after a tool call or a code interpreter run now says so, instead of stopping mid-answer with nothing to explain why. [Commit](https://github.com/open-webui/open-webui/commit/8ab44ed3b153dd8d8d57a444c98d100f281f7f7e), [#27426](https://github.com/open-webui/open-webui/pull/27426), [#27411](https://github.com/open-webui/open-webui/issues/27411)
- 💾 **Errors kept after reloading.** An error that ends a streamed reply is now saved to the conversation, so it is still there when you reload instead of disappearing. [#27365](https://github.com/open-webui/open-webui/pull/27365), [#27074](https://github.com/open-webui/open-webui/issues/27074)
- 💬 **Readable error messages.** Errors in a conversation now always show readable text that wraps instead of running off the edge, including errors that arrive wrapped inside another error.
- 🪝 **Blocked webhook targets look like failures.** A webhook pointing at an address that is not publicly reachable is now skipped with a short warning, instead of an error and a full traceback that read like the server crashing on startup. [Commit](https://github.com/open-webui/open-webui/commit/0671b7aa2b59f5c6235bfa85d6aba54e2ba91353), [#26975](https://github.com/open-webui/open-webui/issues/26975)
- 🕵️ **Values printed in error logs.** A failure no longer prints the contents of nearby variables alongside its traceback, which could put keys and message content into the logs, and a new "LOGURU_DIAGNOSE" setting turns that detail back on for debugging. [Commit](https://github.com/open-webui/open-webui/commit/6aebfd88e938d1cd139068b5737a83a82ff393ed), [#26814](https://github.com/open-webui/open-webui/pull/26814)
- 🪵 **Empty audit exclusion list.** Clearing the list of paths excluded from audit logging no longer switches off auditing altogether, so requests are recorded as intended. [#27370](https://github.com/open-webui/open-webui/pull/27370), [Commit](https://github.com/open-webui/open-webui/commit/2ef6c76f5126ccdef5d1c814004920941275f45d)
- 🗒️ **Readable audit log bodies.** Audit logs that record response bodies now store them as readable text instead of compressed data, so entries are legible whenever a browser requested compression. [#27369](https://github.com/open-webui/open-webui/pull/27369)
- 👍 **Rating in feedback events.** Events sent when someone rates a response now carry the rating that was given, instead of reporting it as empty. [Commit](https://github.com/open-webui/open-webui/commit/300302d43259e119cd88247b3f246bea82b4dd8e), [#26840](https://github.com/open-webui/open-webui/issues/26840)
- ⏱️ **Accurate request timing header.** The processing time reported on each response now includes fractions of a second instead of rounding everything under a second down to zero. [#27368](https://github.com/open-webui/open-webui/pull/27368)
- 📋 **Provider rejection logging.** When a model provider rejects a request, the reason it gave is now recorded in the server logs, so administrators can diagnose failures without querying the provider directly. [#27238](https://github.com/open-webui/open-webui/pull/27238), [#27237](https://github.com/open-webui/open-webui/issues/27237), [#26253](https://github.com/open-webui/open-webui/issues/26253)
- ⏳ **Faster licensed startup.** Instances with a license key no longer wait on the license server during startup, so the app becomes ready to serve traffic without that delay. [Commit](https://github.com/open-webui/open-webui/commit/8f7753331752e72b17ef8f055318ab548a73f4b8), [Commit](https://github.com/open-webui/open-webui/commit/0c7ddbdb4f7dbd46f1dadc3242dbb13b81b47758)
- 📅 **Calendar invitation responses.** Whether you have accepted an invitation is now decided by your own response rather than by whoever created the event, and invitations you decline disappear from your calendar. [#27007](https://github.com/open-webui/open-webui/pull/27007)
- 🗓️ **Schedules written by hand.** A recurrence rule is now read the same way whether it is written in upper or lower case, a start date in the rule is respected, second-by-second rules are understood, and a rule that cannot be supported is refused with a clear message instead of behaving unpredictably. [Commit](https://github.com/open-webui/open-webui/commit/c4ae8c86786fed521960466f6d8eef8af22c2946), [Commit](https://github.com/open-webui/open-webui/commit/2d928df30443516a9a3d6b71b0f31426a2362499), [#27470](https://github.com/open-webui/open-webui/pull/27470)
- 📅 **One calendar event stalling the server.** Working out when a repeating event happens next now walks its rule once rather than re-counting from the beginning for every occurrence, so an event repeating every minute from an old start date can no longer occupy the server for everyone. [#27468](https://github.com/open-webui/open-webui/pull/27468)
- ⏰ **Recurring automation scheduling.** Automations that repeat every few minutes or hours now align to the clock and are no longer wrongly rejected as having no upcoming runs when the server clock is ahead of your timezone. [Commit](https://github.com/open-webui/open-webui/commit/b3aead23da6cf8ebeedbd9fa3b97c7ac1a3f54ec), [#26954](https://github.com/open-webui/open-webui/issues/26954)
### 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.
- 🛠️ **Admin settings moved into settings.** Admin settings and the analytics dashboard are no longer separate pages and now open alongside your personal settings in the settings window, under their own Admin section, with the old links redirecting there. [Commit](https://github.com/open-webui/open-webui/commit/c1460570b7e2897a3e648441a0206ad25e603b8c), [Commit](https://github.com/open-webui/open-webui/commit/3ce3c529365a3cd9e5631b14bfffead6baa2b1ed), [Commit](https://github.com/open-webui/open-webui/commit/667cba1a9561166941f59faf3bfa24038288b449)
- 📁 **Workspace actions in one menu.** Creating, importing, and exporting workspace items no longer have their own buttons on each page and are now reached from a single Create menu in the workspace header, with creating a prompt or knowledge base opening a dialog rather than a separate page. [Commit](https://github.com/open-webui/open-webui/commit/05e3f713175c1eea43a99f29521eb01700c21d3c), [Commit](https://github.com/open-webui/open-webui/commit/f8350360dfd60ff890b73fe2f39aaf20a52ad28b), [Commit](https://github.com/open-webui/open-webui/commit/91277726cd666276ce4b41a722a142eb539089b8), [Commit](https://github.com/open-webui/open-webui/commit/1760b073c7595d4075a91b986520ff8eeeaebc35)
- 🔐 **Administrators no longer reach other people's automations.** Viewing, editing, running and deleting an automation is now limited to the person who created it, so an administrator with a link to someone else's automation is refused rather than allowed through. [Commit](https://github.com/open-webui/open-webui/commit/f798d05586a140f1a6b51f1e51b2b2a63d079d45)
- 🏷️ **Shorter titles without emojis.** Automatically generated titles for chats and notes are now two to four words and no longer include an emoji, and anyone who prefers the old style can restore it by editing the title generation prompt in admin settings. [Commit](https://github.com/open-webui/open-webui/commit/50d3c927bfed3b8dd94fd9f79bff258a84ecbd92), [Commit](https://github.com/open-webui/open-webui/commit/7a9928ef172b7c280c377c86cb52957e39340158)
- 🗂️ **Archived chats moved to settings.** The Archived Chats shortcut is no longer in the user menu, and your archived conversations are now reached through Settings, where they can also be searched and sorted. [Commit](https://github.com/open-webui/open-webui/commit/9f17c5960a0e47a09773da4bba12997a31222fc8), [Commit](https://github.com/open-webui/open-webui/commit/8dd862d3383978f21111e63fb2d6029711abed9a)
- 🔢 **Usage now reports the latest call separately.** In a response's usage block, "prompt_tokens" and "completion_tokens" now carry the counts from the most recent model call rather than the running total, while "input_tokens", "output_tokens" and "total_tokens" stay cumulative, so anything reading the first pair for billing should read the second set instead. [Commit](https://github.com/open-webui/open-webui/commit/df94268e892cbb66675170a6c78846aef23f6e89), [#27031](https://github.com/open-webui/open-webui/issues/27031)
- 🧳 **The "python-jose" library is no longer installed.** Nothing in Open WebUI imports it anymore, so it and the two packages it pulled in have been dropped from the image, and any tool or function that imports it directly now needs to install it itself. [#27444](https://github.com/open-webui/open-webui/pull/27444)
- 📦 **Storage emulator no longer bundled.** The optional Google Cloud Storage emulator is no longer installed as part of the full package, so anyone who relied on it for local storage testing now needs to install "gcp-storage-emulator" themselves. [Commit](https://github.com/open-webui/open-webui/commit/30415c925a18b1ea1c3f2739bd944dd939f020cf)
## [0.10.2] - 2026-07-01
### Added
- 💭 **Streamed reasoning display.** Models that emit thinking or reasoning now show that content as it streams, and it renders correctly in the chat overview and in exported conversations. [Commit](https://github.com/open-webui/open-webui/commit/0b75445ff9a42e37640c034812d0de9b84039e60), [Commit](https://github.com/open-webui/open-webui/commit/af1c0eee89810fa4c36e3eb7e4eba6de685bd7ca), [Commit](https://github.com/open-webui/open-webui/commit/4b08d65597e5b634b7191b0bd6d28feeafcc2a48), [Commit](https://github.com/open-webui/open-webui/commit/fa2abe4cb6a085a4c9045bf8d8ff4b6beffdef6a)
- 🗂️ **Folder uploads to knowledge bases.** Dragging a folder into a knowledge base, or syncing one, now recreates its subfolder structure instead of flattening everything into loose files. [#26130](https://github.com/open-webui/open-webui/issues/26130), [Commit](https://github.com/open-webui/open-webui/commit/2ed8934f5b2bc8a11c74ef2f34cdb62ef024809e)
- 🧠 **Memory system context toggle.** Administrators can now keep memory tools available while choosing not to add stored memories to the system context, using the new 'Memory System Context' toggle in admin settings. [Commit](https://github.com/open-webui/open-webui/commit/4067e357b2ff9e2fb59866d24656e832908fb6fe)
- 🧹 **Tidier automatic memories.** Automatically saved memories now focus on enduring details like preferences and goals and skip one-off things like meals, routine events, or passing mood unless you ask to remember them. [Commit](https://github.com/open-webui/open-webui/commit/80af65c24adac5140a39a2b5687a3b669b86719f)
- 🎙️ **Speech-to-text request format.** OpenAI-compatible speech-to-text can now send audio as either a multipart upload or base64 JSON, selectable in admin audio settings. [Commit](https://github.com/open-webui/open-webui/commit/989c6c13f5d4c5cc255aec77aea14725104d9cb3)
- 🧰 **API configs via environment.** Administrators can now set per-connection Ollama and OpenAI API configurations through the "OLLAMA_API_CONFIGS" and "OPENAI_API_CONFIGS" environment variables. [Commit](https://github.com/open-webui/open-webui/commit/19d8f03bd2c64013b510f2a0eeb3513d452814a3)
- 📡 **Provider failure events.** Failed Ollama and OpenAI-compatible provider requests now emit a structured event describing the error type, provider, and status, giving administrators clearer visibility into upstream failures. [Commit](https://github.com/open-webui/open-webui/commit/4351c78b1e45bb0c5824f9d9eb911b395e343646)
- 🏟️ **Arena models via environment.** Administrators can now define evaluation arena models through the "EVALUATION_ARENA_MODELS" environment variable. [#26174](https://github.com/open-webui/open-webui/issues/26174)
- ♿ **Clearer high-contrast sidebar selection.** With high-contrast mode enabled, the currently selected chat in the sidebar now stands out with stronger colors, making it easier to tell which chat is active. [#26469](https://github.com/open-webui/open-webui/issues/26469), [Commit](https://github.com/open-webui/open-webui/commit/52ee5cb1b3aa8a8ad40b23d72d82db7c3522dbf8)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Thai, Portuguese (Brazil), Catalan, and Spanish 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)
- 🔐 **Fewer unexpected logouts.** A single request returning an authorization error no longer signs you out while your session is still valid, since the app now confirms the session status before redirecting to login. [Commit](https://github.com/open-webui/open-webui/commit/56ee875e21cb3b137a5aa1eca6eb3c0731b14045)
- 🔒 **Web search domain filtering.** Domain allow and block rules for web search results are now matched against the host, closing a gap where some URLs could slip past the filter. [Commit](https://github.com/open-webui/open-webui/commit/688bda09fbe26619613aa487beda8059a5fd55ef)
- 🕵️ **Image prompt log privacy.** Image generation workflows are no longer written to server logs at the default log level, keeping user-authored prompt content out of operator-visible logs. [#26400](https://github.com/open-webui/open-webui/issues/26400), [Commit](https://github.com/open-webui/open-webui/commit/64b92ff08a09b1bf338d5d95b7bf6f9db0d7e857)
- 🗄️ **Safer database upgrades.** Upgrading an existing SQLite database no longer crashes during the user-table migration or corrupts saved user settings, resolving failures that could block startup or break login after an upgrade. [#26403](https://github.com/open-webui/open-webui/issues/26403), [Commit](https://github.com/open-webui/open-webui/commit/c416c6cad69cc4ce44faacdad87cc65862625fb4)
- ⚙️ **Saving settings as a non-admin.** Non-admin users can once again save their interface settings, such as the default model and theme, which previously failed with a server error while the interface incorrectly reported success. [#26627](https://github.com/open-webui/open-webui/issues/26627), [Commit](https://github.com/open-webui/open-webui/commit/9866a02863c12c466100aed832f0006225759493)
- 🕒 **Sidebar chat timestamps.** Chats in the sidebar now show when they were last active instead of when they were created, so the time label matches their position in the list and refreshes after each new message. [#26454](https://github.com/open-webui/open-webui/pull/26454), [#26451](https://github.com/open-webui/open-webui/issues/26451)
- 🎯 **Default model after refresh.** Your selected model is no longer cleared when you reload the page, as model selection now waits for the model list to finish loading. [Commit](https://github.com/open-webui/open-webui/commit/b6d4baeb7ea9bc83e5486ca98b3cae07d3869aa7)
- ⏳ **Tool dialogs no longer hang.** Dismissing a tool or function input dialog by clicking outside it now cancels the pending request instead of leaving the chat spinning indefinitely. [#26417](https://github.com/open-webui/open-webui/issues/26417), [Commit](https://github.com/open-webui/open-webui/commit/0016266c0652757e32aa44f2e1fb7311ac08db51)
- 🐍 **Reliable code execution loading.** Running Python code in chat now loads its runtime reliably, fixing sandbox startup failures that broke code execution and the Pyodide file viewer in recent releases. [#26625](https://github.com/open-webui/open-webui/pull/26625), [#26390](https://github.com/open-webui/open-webui/issues/26390)
- 🤖 **Models with null capabilities.** Chatting with a model whose capabilities are unset no longer fails with an error when the memory feature or automations are involved. [#26412](https://github.com/open-webui/open-webui/issues/26412), [Commit](https://github.com/open-webui/open-webui/commit/650b81792582268345073ce3757ac14356189d75), [Commit](https://github.com/open-webui/open-webui/commit/0016266c0652757e32aa44f2e1fb7311ac08db51)
- 🔎 **Searchable responses.** Chat search again finds assistant messages whose text is stored as structured output, which were previously skipped. [#26405](https://github.com/open-webui/open-webui/pull/26405)
- 🔔 **Chat notification previews.** Background chat completion notifications and toasts now show a clean response preview instead of appearing blank for messages stored as structured output. [Commit](https://github.com/open-webui/open-webui/commit/c98d8ecaccc7284573b050e88b3857c4aeba3860)
- 💾 **Banner and config startup.** Setting configuration such as "WEBUI_BANNERS" no longer causes a startup failure, since admin configuration values are now stored correctly regardless of their data type. [#26431](https://github.com/open-webui/open-webui/issues/26431), [Commit](https://github.com/open-webui/open-webui/commit/ab22fe64bdd10ba86845dcd46dfd7f619b22366f)
- 📑 **RAG Template visibility.** The RAG Template editor now stays visible in admin document settings even when Bypass Embedding and Retrieval is enabled, since the template still applies to document content in that mode. [#26126](https://github.com/open-webui/open-webui/issues/26126), [Commit](https://github.com/open-webui/open-webui/commit/8fe480250f649824441a7b4d725a502683134713)
- 🧬 **Editing derived models.** Editing a workspace model no longer clears its base model, including when that base is a preset or the model itself. [Commit](https://github.com/open-webui/open-webui/commit/092b5857bbda01be53619e26f42b1fa9b64a2b44), [Commit](https://github.com/open-webui/open-webui/commit/54f31c630afa75e9c81acaa048e1bd901254ba92)
### 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.
## [0.10.1] - 2026-06-29
### Fixed
- 🤝 **Shared folder read-only chats no longer sign users out.** Opening or reading chats from shared folders now keeps the current session active when a resource-level access error is returned, instead of incorrectly showing "Session expired. Please sign in again."
## [0.10.0] - 2026-06-29
### Added
- 🤝 **Share folders with your team.** You can now share a folder and the chats inside it with specific users, groups, or everyone, with read or write access; people you share with see shared folders in their sidebar and open the chats in a read-only view when they are not the owner, and administrators control who is allowed to share folders with a new "Folders Sharing" permission that is off by default. [Commit](https://github.com/open-webui/open-webui/commit/5019af79a0c45743ede8c9ff37d68f768e7f6174), [Commit](https://github.com/open-webui/open-webui/commit/38920c0ed1f6ad5fe3bb9d12898fa968ead3634a), [Commit](https://github.com/open-webui/open-webui/commit/d65ac445a43348c5f0323d54c37397ae7f483cb8), [Commit](https://github.com/open-webui/open-webui/commit/c783fd30f20d6be5028cf337bc5e5c2f9afbd3f8), [Commit](https://github.com/open-webui/open-webui/commit/45fcf272ef51c84cb01c1454589da3f98e4adc2c), [Commit](https://github.com/open-webui/open-webui/commit/76854d14246660af8222a5302513020e1f36c4f3), [Commit](https://github.com/open-webui/open-webui/commit/084d040e220ee39f62757d928d839646e813fb25), [Commit](https://github.com/open-webui/open-webui/commit/10558173fb155c63403aa8d80f16f8a3ccfa72a6)
- 🗜️ **Automatic context compaction for long chats.** Conversations that grow past a configurable token threshold can now be summarized automatically so they stay within a model's context window, with a notification shown while it happens; administrators can enable it, set the threshold, customize the summarization prompt, and lower the threshold per model. It is off by default. [Commit](https://github.com/open-webui/open-webui/commit/3f0c0e0a0ddff841b015f96f9649c6999a435c73), [Commit](https://github.com/open-webui/open-webui/commit/7f08376f0c06e1a7fba983a2fa93deb8dfbe7cb0), [Commit](https://github.com/open-webui/open-webui/commit/8934bfb04bf366aece872028944e280c25e36d3e), [#19594](https://github.com/open-webui/open-webui/issues/19594)
- 🖥️ **Open WebUI Computer agent support.** Open WebUI can now connect to Open WebUI Computer through its OpenAI-compatible gateway, letting chats run full agent sessions on your own machine with file, terminal, git, and web access. [GitHub](https://github.com/open-webui/computer)
- 🚀 **Much faster hybrid search on large knowledge bases.** Hybrid search now runs natively in the database on pgvector setups instead of loading an entire collection into memory, so querying large knowledge bases is dramatically faster. [Commit](https://github.com/open-webui/open-webui/commit/223f484ded01d092979693341dc03351a9fa17fa), [#20737](https://github.com/open-webui/open-webui/discussions/20737)
- 🗂️ **External knowledge bases.** Knowledge bases can now be backed by an external retrieval source through configurable external knowledge connections, so you can search an existing external system from chat instead of only Open WebUI's built-in store. [Commit](https://github.com/open-webui/open-webui/commit/15c7e374384488effc3d6059d09b3a8aa79c618d)
- 🧠 **Reworked memory system.** Memory has been overhauled with distinct memory types — long-lived personal memories and per-conversation context — managed through a structured add, update, and delete flow, giving models a more reliable way to remember and apply what they've learned about you. [Commit](https://github.com/open-webui/open-webui/commit/dbdcfd8c6080c284024052a482e589de226dbf05), [Commit](https://github.com/open-webui/open-webui/commit/7e13fd7ad19c28ba34502bde6c709665f7a6808c), [Commit](https://github.com/open-webui/open-webui/commit/2560533c1a8a2b2a0f7b47becf3703031053ad30), [Commit](https://github.com/open-webui/open-webui/commit/8977a10a2b1e150393635dc5c24e59660d0f2da9), [Commit](https://github.com/open-webui/open-webui/commit/260f3c3a22c55f15ca8f06c5314b23f2a9eb1739), [Commit](https://github.com/open-webui/open-webui/commit/b0487dd6dd942a757828a25aba92e9feef685275), [Commit](https://github.com/open-webui/open-webui/commit/a285a390c12e27e614d1ba9ffb92d1a0e49e7dfa), [Commit](https://github.com/open-webui/open-webui/commit/70e4ffcc6526c1bc90dbfdc0287283574b12c18b), [Commit](https://github.com/open-webui/open-webui/commit/2c4e1fce8f40b0cb5028f1afcb184b6e58c33041), [Commit](https://github.com/open-webui/open-webui/commit/c7e634776d7e77556d149b6cc884ed64363648a9)
- 🧩 **New plugin primitive: the Event function.** Where pipe, filter, and action functions all run inside a conversation, the new Event function is the first primitive that hooks into the system itself: it runs your own Python in response to events emitted across the whole application — sign-ups, configuration changes, file uploads, role changes, deletions, startup and shutdown, and more. That makes a new class of behavior possible directly inside Open WebUI, from onboarding and access control to auditing, lifecycle automation, and external integrations. Comes with starter boilerplate in the function editor. [Commit](https://github.com/open-webui/open-webui/commit/e124c2656a4c2092b070e570e35b2f0fb7f584de), [Docs](https://docs.openwebui.com/features/extensibility/plugin/functions/event)
- 🔔 **New event system with webhooks.** Open WebUI now emits events for a wide range of system activity — sign-ins, configuration changes, startup, and actions across chats, knowledge, files, and more. Administrators can send these as outbound webhooks, route them to specific users or groups, and manage which events go where from a new event settings admin page. [Commit](https://github.com/open-webui/open-webui/commit/b5c43968db0ea1556b228d143ae5946dc4e944ba), [Commit](https://github.com/open-webui/open-webui/commit/745396867888718289a2dfcf0809b3e162e00629), [Commit](https://github.com/open-webui/open-webui/commit/5576e6ed8a80a4032b7c6cb3ee0cda0254019355), [Commit](https://github.com/open-webui/open-webui/commit/7b55a63fc7ee323e9114713ce1d2f3f688aa37e6), [Commit](https://github.com/open-webui/open-webui/commit/1a8e1a993928a28b9d814c77b2aef0361b630f27), [Commit](https://github.com/open-webui/open-webui/commit/ede39d82de05eeb7591329679c8cab97753e5ff0), [Commit](https://github.com/open-webui/open-webui/commit/8f890f0b43aed3d42b9e3d954e25e54e37d526d0), [Commit](https://github.com/open-webui/open-webui/commit/741b64edb6c2ff04c4b787528cfca1c5b66a1a27), [Commit](https://github.com/open-webui/open-webui/commit/303c426c3fffafc2369205021a82659ee8715a85), [#1240](https://github.com/open-webui/open-webui/issues/1240), [#16426](https://github.com/open-webui/open-webui/pull/16426)
- 🔐 **Configure authentication from the admin panel.** LDAP and OAuth/OIDC settings now have a dedicated Authentication settings page, so providers can be configured from the admin interface. [Commit](https://github.com/open-webui/open-webui/commit/5cdcdbaeec9fc8156721c38c33ec37956962871c), [#12945](https://github.com/open-webui/open-webui/pull/12945)
- 🏷️ **More custom header variables.** Custom request headers now support "{{USER_MESSAGE_ID}}", "{{USER_MESSAGE_PARENT_ID}}", and "{{TASK}}", letting connected services tell apart real user messages from automated background requests like title, tag, and follow-up generation. [Commit](https://github.com/open-webui/open-webui/commit/f85cb27ef835aa76aff7de6176bf2159ba392061)
- 📄 **File details forwarded to external document extractors.** External custom document-extraction servers now receive the file's ID, name, and content type, and these are also available as custom header variables, so extraction can be tailored per file. [Commit](https://github.com/open-webui/open-webui/commit/b1c2536ed2f8639efade04618018e6de9b332df2), [#26259](https://github.com/open-webui/open-webui/issues/26259)
- 🎰 **Last model pre-selected for new slots.** When you add another model to a multi-model chat, the slot now defaults to the model you last picked instead of starting empty. [#25974](https://github.com/open-webui/open-webui/pull/25974)
- ⚡ **Faster model overview.** The admin model overview now loads its feedback history and tags through batched queries, so it opens noticeably faster on instances with many chats. [Commit](https://github.com/open-webui/open-webui/commit/40c09167cd6de1c853a5dd03c88b4fdcb279dfe1)
- 🏎️ **Lighter channel profile previews.** Profile previews in channels now load a person's details only when you hover to open one, rather than fetching them for every message up front. [Commit](https://github.com/open-webui/open-webui/commit/4f69c33de0e9a8fde4f16d0b2f1ed8aac8741772)
- ↩️ **Reset permissions to defaults.** The group and default permission dialogs now include a button to restore all permissions back to their built-in defaults in one step. [#25931](https://github.com/open-webui/open-webui/pull/25931)
- 📥 **Chat import permission.** Administrators can now control whether users are allowed to import or clone chats, with a new "Allow Chat Import" permission. [Commit](https://github.com/open-webui/open-webui/commit/edf3ae920989b01383be543e7379d3eade03c0b6), [Commit](https://github.com/open-webui/open-webui/commit/9ccda6715c3b2dc2cbc1302d2396b2f0233bdea8), [Commit](https://github.com/open-webui/open-webui/commit/ed4cb358a06fc6962b378f20ef846fd2b0af90bc), [#25927](https://github.com/open-webui/open-webui/pull/25927)
- 🔔 **Per-group user webhook permission.** Administrators can now control which users may set a personal notification webhook, with a new "User Webhooks" permission. [#25923](https://github.com/open-webui/open-webui/pull/25923)
- ✍️ **Customizable autocomplete prompt.** Administrators can now set a custom prompt template for autocomplete generation from the admin interface settings. [Commit](https://github.com/open-webui/open-webui/commit/4dbb2f94a66d6e0035e2da857ddb6a841a68f862), [#25879](https://github.com/open-webui/open-webui/pull/25879)
- 🔑 **Configurable secret key length.** The auto-generated secret key length can now be set with a new environment variable, instead of always using a fixed length. [Commit](https://github.com/open-webui/open-webui/commit/e473ab1231abedcb188c259b42ae7f2390739223), [#25906](https://github.com/open-webui/open-webui/pull/25906)
- 🏟️ **Arena evaluation models configurable via environment.** Arena evaluation models can now be defined through an environment variable, which previously could not be set that way. [Commit](https://github.com/open-webui/open-webui/commit/fd56086e793a0eceb07a55ff972a5492d8f8a285)
- ✏️ **Edit prompts from the menu.** The prompts list now has an Edit option in each prompt's menu, taking you straight to its editor. [#25789](https://github.com/open-webui/open-webui/pull/25789)
- 📋 **Clone automations.** Automations now have a Clone option in their menu, so you can duplicate one as a starting point. [#25790](https://github.com/open-webui/open-webui/pull/25790)
- 🔁 **Recurring calendar events.** The calendar event editor now includes a repeat option, so events can recur on a schedule. [#25865](https://github.com/open-webui/open-webui/pull/25865)
- 🧷 **Separate skills import and export permissions.** Administrators can now control importing and exporting skills independently, with new skills import and export permissions. [#25921](https://github.com/open-webui/open-webui/pull/25921)
- 🏷️ **Filter admin models by tag.** The admin Models settings page now has a tag filter for narrowing the model list by base-model tags. [Commit](https://github.com/open-webui/open-webui/commit/2bdd2ab94eefd3d75dd6511c445e302f82221b5d)
- 📊 **Sortable analytics chat list.** The model chat list in analytics now has sortable column headers, so you can order it by title, last updated, or user. [Commit](https://github.com/open-webui/open-webui/commit/3730a9eaac68dff60b3ae5b4ed160b91480d66eb), [#26168](https://github.com/open-webui/open-webui/pull/26168)
- 🔐 **Argon2 password hashing option.** Password hashing can now use Argon2 through a configurable algorithm setting, removing the 72-byte password length limit that came with the previous default. [Commit](https://github.com/open-webui/open-webui/commit/33cd199e6dffddd4ee8974af41ebb894871d74c1), [Commit](https://github.com/open-webui/open-webui/commit/a70a6589afad0b429cdd77afa62163391f406a87), [#25656](https://github.com/open-webui/open-webui/pull/25656)
- 🔐 **Optional encryption of valve values at rest.** Tool and function valve values can now be encrypted at rest through a new opt-in setting, with existing stored values migrated automatically, so sensitive settings like API keys aren't kept in plaintext. [Commit](https://github.com/open-webui/open-webui/commit/b4073f6378392b23a3954e33031bf5e1d98e090a), [#23721](https://github.com/open-webui/open-webui/pull/23721)
- 🗄️ **AWS RDS IAM database authentication.** The database connection can now authenticate using AWS RDS IAM tokens through a new opt-in setting, instead of only a static password. [Commit](https://github.com/open-webui/open-webui/commit/c0c6c2181a8dc57b62e8a5eabd550bf89db7ffed), [#23580](https://github.com/open-webui/open-webui/pull/23580)
- 🔓 **Automatic auth for models with OAuth 2.1 tools.** When a model uses tools that require OAuth 2.1, Open WebUI now initiates the authorization flow automatically instead of failing the request. [Commit](https://github.com/open-webui/open-webui/commit/ae5d23f2267845922c2acb507a4a432908d03b41), [#23325](https://github.com/open-webui/open-webui/pull/23325), [#23272](https://github.com/open-webui/open-webui/issues/23272)
- 🔤 **Custom tokenizer for token-based text splitting.** Token-based document splitting can now use a configurable Hugging Face tokenizer model, so chunking can match the tokenizer of the model you use. [Commit](https://github.com/open-webui/open-webui/commit/bb6b2db88b1e82395531f67db4f6accd49d8b9eb), [#24139](https://github.com/open-webui/open-webui/pull/24139)
- 🔒 **Restrict OAuth scopes requested from MCP servers.** A new setting lets administrators limit which OAuth scopes Open WebUI requests when connecting to MCP servers. [Commit](https://github.com/open-webui/open-webui/commit/7be009649a0a94008484335c492ab0af18fde41f), [#25981](https://github.com/open-webui/open-webui/pull/25981), [#25978](https://github.com/open-webui/open-webui/issues/25978)
- 🧩 **Filter Outlet Hook can now run on API requests and responses.** A filter function's outlet hook now runs for direct API callers, including streaming responses, so response post-processing isn't limited to the web interface; this is controlled by a new setting and on by default. [Commit](https://github.com/open-webui/open-webui/commit/390e200f76877b185002c88b4dda27b123d29e83), [#25650](https://github.com/open-webui/open-webui/pull/25650)
- 🖥️ **Setting for terminal sidebar auto-open.** A new interface setting controls whether the files sidebar opens automatically when you select a terminal. [Commit](https://github.com/open-webui/open-webui/commit/958237473f8cbde97eb0df8c21f2a4de088c4459), [#25628](https://github.com/open-webui/open-webui/pull/25628)
- 📌 **Reorder pinned notes by dragging.** Pinned notes in the sidebar can now be dragged to reorder them. [#25677](https://github.com/open-webui/open-webui/pull/25677)
- 🔎 **Chat actions in search.** The search dialog now offers a context menu on each result, so you can act on a chat directly from search. [#25490](https://github.com/open-webui/open-webui/pull/25490)
- 🔎 **Snippets in chat search results.** Searching your chats now shows a snippet of the matching content in each result, so you can tell results apart at a glance. [Commit](https://github.com/open-webui/open-webui/commit/0eba3df1199f56e8ac77213772a41313a3237296), [Commit](https://github.com/open-webui/open-webui/commit/67a7b23b85d2e3ce6b094b682ba9f07dc453d355), [Commit](https://github.com/open-webui/open-webui/commit/8927c9bb3d4b04f4fc8e443f42089f2a551276bc), [#25178](https://github.com/open-webui/open-webui/pull/25178)
- 📝 **Formatted valve descriptions.** Valve descriptions for tools and functions now render Markdown, so they can include formatting and links. [Commit](https://github.com/open-webui/open-webui/commit/7c0b0e42f5afb0e9c39bd2d42e4822d64d0c7b3e)
- 🔽 **Dropdown inputs for valve options.** Valve and confirmation inputs can now present a set of options as a dropdown instead of free text, making fixed-choice settings easier to configure. [Commit](https://github.com/open-webui/open-webui/commit/422a4768ea7428b5dd6d401ccfba00e1e86eb98a), [#26278](https://github.com/open-webui/open-webui/pull/26278)
- 🔌 **Control the OAuth resource parameter for MCP connectors.** MCP connectors can now be set to always send, never send, or automatically decide whether to include the OAuth resource parameter, so they work with providers that reject it. [Commit](https://github.com/open-webui/open-webui/commit/5576e6ed8a80a4032b7c6cb3ee0cda0254019355)
- 🔎 **SERPHouse web search.** SERPHouse can now be used as a web search provider. [Commit](https://github.com/open-webui/open-webui/commit/3a232f5e9a4d31a6b74cb34d007b581feeb2f005), [Commit](https://github.com/open-webui/open-webui/commit/dd4f43bfdb793c4276e6468b65fc32d9b881578a), [#26254](https://github.com/open-webui/open-webui/pull/26254)
- 🔎 **Microsoft Web IQ web search.** Microsoft Web IQ can now be used as a web search provider, with a matching page-browse loader. [#26178](https://github.com/open-webui/open-webui/pull/26178)
- ⚠️ **Optional web search confirmation.** Administrators can now require users to confirm before a web search runs, with a banner and message making it clear when search is about to be used. [Commit](https://github.com/open-webui/open-webui/commit/fa76764c3b7f99c5adacd34dabd51ead09542c13), [#24942](https://github.com/open-webui/open-webui/pull/24942)
- 🪪 **Client User-Agent forwarded to model backends.** The browser's User-Agent is now passed through to all model backends, so upstream services can see the originating client. [#26333](https://github.com/open-webui/open-webui/pull/26333)
- 🖐️ **Drag items from the sidebar into chat.** Folders, notes, and models — including pinned notes — can now be dragged from the sidebar into the chat input. [#25771](https://github.com/open-webui/open-webui/pull/25771), [Commit](https://github.com/open-webui/open-webui/commit/dc1bc41d2e), [#26384](https://github.com/open-webui/open-webui/pull/26384)
- 🏷️ **Tag suggestions in the model editor.** The model editor now suggests existing tags as you type, making it easier to reuse a consistent set. [Commit](https://github.com/open-webui/open-webui/commit/b58b0ea7ca849b89d217e1077498a8a3fc92471f), [#25703](https://github.com/open-webui/open-webui/pull/25703)
- 🗣️ **Voice suggestions in the model editor.** The model editor now offers a dropdown of available text-to-speech voices, making it easier to pick one. [Commit](https://github.com/open-webui/open-webui/commit/a5c945940134b957dbad47790b5baafeecdac6c4), [#25706](https://github.com/open-webui/open-webui/pull/25706)
- 🎛️ **Unified model picker for workspace base model.** Choosing a base model in the model editor now uses the searchable model selector instead of a plain field, making it easier to find and pick the right model. [Commit](https://github.com/open-webui/open-webui/commit/c89fd237b822877bffbb33a37402622983c7189d), [#24576](https://github.com/open-webui/open-webui/issues/24576)
- 🔍 **Searchable pickers in the model editor.** Attaching actions, filters, tools, knowledge, and skills to a model now uses type-to-search pickers instead of long checkbox lists, making large libraries easier to manage. [Commit](https://github.com/open-webui/open-webui/commit/61cee42ded4e84e31d7cc9b168994ef165efa8ca)
- 🖼️ **iPhone images work with OpenAI image editing.** Uploaded images are now normalized before being sent to OpenAI image editing, fixing edits that failed for certain iPhone photo formats, with a new admin toggle to control the behavior. [Commit](https://github.com/open-webui/open-webui/commit/39837e0a3afd17b7ff617d97dddf4c5d6446e42d), [Commit](https://github.com/open-webui/open-webui/commit/2d3035a1122123df471ac9d0a591a9465a8212e2), [#26252](https://github.com/open-webui/open-webui/pull/26252), [#26249](https://github.com/open-webui/open-webui/issues/26249)
- 🟢 **Loaded-model indicator for llama.cpp.** Models served through llama.cpp now report whether they're currently loaded in memory, including the sleeping state, so the loaded indicator works for them too. [Commit](https://github.com/open-webui/open-webui/commit/b696c5deff15d4c85c84c5bac244f062b1bc879a)
- 🧱 **Structured model output rendered on the client.** Reasoning, tool calls, and server-side tool steps such as web and file search are now rendered in the browser from the model's structured output instead of being flattened into the message text on the server, giving more accurate and editable rendering of these items. [Commit](https://github.com/open-webui/open-webui/commit/0443ab3a61492799f1aaa449f89cbd8aa5912f57), [Commit](https://github.com/open-webui/open-webui/commit/c33fadc26671190c94d86485e6e2ef2f6fd486a3)
- 📜 **Custom CA bundle for outbound connections.** A new environment variable lets you point Open WebUI at a custom CA certificate bundle, and the per-connection SSL settings now accept a bundle path, so deployments behind a corporate or internal CA can keep certificate verification on instead of disabling it. [Commit](https://github.com/open-webui/open-webui/commit/a54878b14f044d4aa1d8cf5be6f8ce9fc4285438), [Commit](https://github.com/open-webui/open-webui/commit/8b9e28b50354307a314111262f6737b6d8aa4685)
- 🖥️ **More terminal server orchestrator controls.** Admins connecting an orchestrator terminal server can now configure session lifecycle policies and refresh or reset running terminal sessions, including targeting only idle ones, from the connection settings. [Commit](https://github.com/open-webui/open-webui/commit/7e8153e889a59afe4cf77261ea5e1ef5a66665f1)
- 📁 **Terminal file browser can stay within a root folder.** The terminal file navigator now anchors to a defined root and home directory, so users can be kept within their workspace instead of browsing into system folders by accident. [Commit](https://github.com/open-webui/open-webui/commit/a0c2ec3d2cf8d696ede479211330eec2da360d39)
- 🧠 **Memory toggle follows the server default.** When a user hasn't set their own memory preference, it now follows the admin's global memory setting instead of defaulting to off. [#25909](https://github.com/open-webui/open-webui/pull/25909)
- 🧹 **Unshare all shared chats at once.** The Shared Chats dialog now has a button to stop sharing every shared chat in one action. [#25848](https://github.com/open-webui/open-webui/pull/25848)
- 📈 **Richer analytics with a date picker.** The analytics dashboard now lets you choose a date range and shows additional columns. [#25922](https://github.com/open-webui/open-webui/pull/25922), [#25919](https://github.com/open-webui/open-webui/issues/25919)
- 🔢 **Chat and file counts in their dialogs.** The Chats and Files dialogs now show the total number of chats and files in their titles. [#25872](https://github.com/open-webui/open-webui/pull/25872), [#25873](https://github.com/open-webui/open-webui/pull/25873)
- ⚡ **Faster math rendering.** Rendered math is now cached and reused, so messages with repeated or unchanged math expressions render more efficiently. [#25847](https://github.com/open-webui/open-webui/pull/25847)
- ⚡ **Lighter Markdown setup.** Markdown extension setup now runs once instead of on every render, avoiding repeated work and extension stacking. [#25837](https://github.com/open-webui/open-webui/pull/25837)
- ⚡ **Snappier read-only code blocks.** Read-only code blocks now skip language auto-detection, so they render faster. [#25824](https://github.com/open-webui/open-webui/pull/25824)
- ⚡ **Non-blocking audio model loading.** Loading speech models no longer blocks the server, keeping it responsive while they initialize. [#25806](https://github.com/open-webui/open-webui/pull/25806)
- ⚡ **Faster URL safety checks.** The safety check on fetched URLs now resolves addresses off the main loop, so it no longer blocks other work. [#25825](https://github.com/open-webui/open-webui/pull/25825)
- ⚡ **Fewer queries for channel reactions and replies.** Channel reactions and thread replies now load through batched queries, reducing database load on busy channels. [#25831](https://github.com/open-webui/open-webui/pull/25831)
- ⚡ **Lighter streaming.** Streaming responses now skip re-processing message content that hasn't changed, reducing work on every update. [#26325](https://github.com/open-webui/open-webui/pull/26325), [#26326](https://github.com/open-webui/open-webui/pull/26326)
- ⚡ **Smoother tool-call rendering.** Displaying tool calls now parses their content iteratively, avoiding slowdowns on deeply nested data. [#26146](https://github.com/open-webui/open-webui/pull/26146)
- ⚡ **Hidden tool-call details cost nothing.** When tool-call arguments are collapsed, they are no longer rendered behind the scenes, noticeably speeding up chats with heavy tool use. [Commit](https://github.com/open-webui/open-webui/commit/b7934e918223ec0a9e972accd647a8654e503156), [#26147](https://github.com/open-webui/open-webui/pull/26147)
- ⚡ **Leaner knowledge-file reading for agents.** The built-in tools that let a model read knowledge files now return output in bounded, paginated chunks with a default and a hard cap, instead of potentially returning an entire large file at once, sharply reducing token usage. [Commit](https://github.com/open-webui/open-webui/commit/a285a390c12e27e614d1ba9ffb92d1a0e49e7dfa), [#26139](https://github.com/open-webui/open-webui/issues/26139)
- ⚡ **Lighter, faster file search on large knowledge bases.** Listing and searching files no longer returns each file's full extracted text by default, and content matching is now length-bounded, so these requests are far lighter and searching across very large knowledge bases is dramatically faster. [Commit](https://github.com/open-webui/open-webui/commit/36d08fa2a7), [Commit](https://github.com/open-webui/open-webui/commit/46c1d6591badb6ab567ba1b8fae23475d5da105a), [Commit](https://github.com/open-webui/open-webui/commit/ab84bbf08c5935f1a19044ef581986f83311da8b), [#25774](https://github.com/open-webui/open-webui/pull/25774), [#25741](https://github.com/open-webui/open-webui/issues/25741), [#26145](https://github.com/open-webui/open-webui/pull/26145), [#25867](https://github.com/open-webui/open-webui/issues/25867)
- ⚡ **Faster password hashing and bulk user import.** Password hashing and verification no longer block the server, and importing users from a CSV is now processed in a single batch, keeping large imports and sign-ins responsive. [Commit](https://github.com/open-webui/open-webui/commit/6fdf9b4340), [#25804](https://github.com/open-webui/open-webui/pull/25804), [#25805](https://github.com/open-webui/open-webui/pull/25805)
- ⚡ **Non-blocking model downloads.** Downloading large Ollama models no longer blocks the server on file reads and checksums, keeping it responsive during big downloads. [#25829](https://github.com/open-webui/open-webui/pull/25829)
- ⚡ **Non-blocking uploads and link fetches.** Hashing uploaded files and fetching URLs now run off the main loop, so large uploads and link previews don't hold up other requests. [#25822](https://github.com/open-webui/open-webui/pull/25822)
- ⚡ **More blocking work moved off the main loop.** Additional blocking operations in audio, pipelines, and plugin handling now run in worker threads, keeping the server responsive under load. [#26381](https://github.com/open-webui/open-webui/pull/26381)
- ⚡ **Unreachable backends don't stall model loading.** Loading models and tool servers no longer blocks on backends that are down or slow to respond, so the model list stays responsive when one connection is unreachable. [#26289](https://github.com/open-webui/open-webui/pull/26289)
- ⚡ **Batched streaming updates.** Streaming responses now group small updates of the same type before sending them, reducing overhead during fast token streams and tool-call output. [Commit](https://github.com/open-webui/open-webui/commit/7240517807a8b0097065f7cbbb384d34084f90fd), [#26202](https://github.com/open-webui/open-webui/pull/26202)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Updated translations.** Catalan, Brazilian Portuguese (pt-BR), Irish, German (de-DE), and Spanish (es-ES) translations were updated.
### 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 base write access enforced on upload.** Attaching an uploaded file to a knowledge base now requires the same write access as the rest of the knowledge API, so users without write access can no longer add files to a collection by referencing its ID. [#26001](https://github.com/open-webui/open-webui/pull/26001)
- 🗝️ **API key permission enforced on all key endpoints.** Viewing and deleting API keys now respects the API keys permission, matching the protection already applied to key creation. [#25992](https://github.com/open-webui/open-webui/pull/25992)
- 🔊 **Text-to-speech permission enforced on the speech endpoint.** The OpenAI speech proxy now honors the text-to-speech permission, so it can no longer be used by people who are not allowed to use that feature. [#25993](https://github.com/open-webui/open-webui/pull/25993)
- 🎲 **Model access enforced on arena fallback.** Reaching a model indirectly through an arena model on background and task requests now enforces that model's access rules, closing a path that could otherwise bypass them. [#26046](https://github.com/open-webui/open-webui/pull/26046)
- ⏰ **Scheduled automations stop for deactivated accounts.** Scheduled automations now re-check the owner's account status and permissions before each run, so they stop when an account is deactivated or has automations access revoked. [#26047](https://github.com/open-webui/open-webui/pull/26047)
- 🚧 **Heavily encoded paths rejected behind the proxy.** Request paths that remain encoded after repeated decoding are now rejected instead of forwarded, preventing a path traversal that could otherwise slip through. [#26050](https://github.com/open-webui/open-webui/pull/26050)
- 🌐 **Image URL fetches hardened against DNS rebinding.** Fetching user-supplied image URLs now re-checks the destination address at connection time, closing a path that could be used to reach internal addresses behind a public hostname. [#25960](https://github.com/open-webui/open-webui/pull/25960)
- 🛂 **Web fetch blocklist matches on hostname.** The web fetch filter now matches entries against the request's hostname on domain boundaries, so blocked hosts can no longer slip through with an added path and lookalike domains are no longer mistaken for allowed ones. [#25949](https://github.com/open-webui/open-webui/pull/25949)
- 🪪 **MCP connectors request least-privilege scopes.** MCP connectors that register dynamically over OAuth now request only the scopes for the specific resource rather than the authorization server's full catalog. [#25958](https://github.com/open-webui/open-webui/pull/25958)
- 🙈 **Channel member lists no longer expose private data.** Viewing a channel's members now returns only basic profile details, instead of also exposing other members' settings, linked-account data, and personal information. [Commit](https://github.com/open-webui/open-webui/commit/fbcdcf146b99b5002705060a8243eee769108f9e)
- 🛟 **SCIM sync can't demote an admin.** A SCIM provisioning sync that marks a user inactive can no longer strip an existing administrator's role, preventing an instance from being locked out of its own administration. [#25948](https://github.com/open-webui/open-webui/pull/25948)
- 👻 **Collaborative notes reject unauthenticated presence events.** The remaining real-time note-collaboration events now require an authenticated session, so presence and cursors can no longer be spoofed by someone who only knows a note's ID. [#25946](https://github.com/open-webui/open-webui/pull/25946)
- ⏱️ **Login timing no longer reveals which accounts exist.** Sign-in now takes the same amount of time whether or not an account exists, removing a timing difference that could be used to discover valid accounts. [Commit](https://github.com/open-webui/open-webui/commit/993e74912199c66c522f08ec81abe31d76985e39), [Commit](https://github.com/open-webui/open-webui/commit/7b29834d4216e5db70b68f3598fa1ad654d3512b)
- 🔌 **Terminal connections can't be redirected to another user.** Terminal session identifiers are now safely encoded before being passed upstream, closing a way to tamper with the connection's user identity. [#26042](https://github.com/open-webui/open-webui/pull/26042)
- 📡 **Real-time events only reach your own session.** The server now verifies that a real-time event is delivered only to the requesting user's own active session, instead of trusting a client-supplied session identifier. [#25763](https://github.com/open-webui/open-webui/pull/25763)
- 🔓 **Revoked sessions are rejected on real-time connections.** Real-time and terminal WebSocket connections now honor token revocation and expiry, so a signed-out or expired session can no longer keep a live connection open. [Commit](https://github.com/open-webui/open-webui/commit/33b91bd8ae8a100a5a306c91441a7d0b422c4cde), [#25764](https://github.com/open-webui/open-webui/pull/25764), [#25686](https://github.com/open-webui/open-webui/pull/25686)
- 🕳️ **Another DNS-rebinding gap closed in URL fetching.** Fetching a URL's content now re-checks the destination address at connection time, closing another path that could reach internal addresses behind a public hostname. [#25775](https://github.com/open-webui/open-webui/pull/25775)
- 🗣️ **Azure speech input is escaped.** Voice and language values are now escaped when building Azure text-to-speech requests, preventing malformed or injected markup. [#25776](https://github.com/open-webui/open-webui/pull/25776)
- ⚙️ **Interface settings update respects its permission.** Saving interface settings now enforces the interface permission, so users without it can no longer change those settings through the API. [#25996](https://github.com/open-webui/open-webui/pull/25996)
- 🗄️ **Unknown knowledge collections are denied by default.** Retrieval now rejects unknown or unscoped collection names by default, closing a legacy path that could be used to reach collections outside the normal access checks. [Commit](https://github.com/open-webui/open-webui/commit/d99ac7d3f83b25161ca775229150c8f7c74cceee)
- 🙈 **Error responses no longer leak internals.** Server error responses now return sanitized messages instead of raw exception text, so internal details aren't exposed to signed-in users. [Commit](https://github.com/open-webui/open-webui/commit/ee5de69e374aabf5631da18a5bbc1c285ee6f7a1), [Commit](https://github.com/open-webui/open-webui/commit/0cc331d1c60341bb06b78ceeecfb2db86179c93e), [Commit](https://github.com/open-webui/open-webui/commit/396d9ac18193d43e40fb9d068075d4b780e971d7), [Commit](https://github.com/open-webui/open-webui/commit/0883638027a9b3cb7c9851f031c4f5fc1af1f25d), [#26375](https://github.com/open-webui/open-webui/pull/26375), [#26374](https://github.com/open-webui/open-webui/issues/26374)
- 📏 **Upload size limit enforced on the server.** The maximum upload size is now enforced server-side, so it can't be bypassed by a client that ignores the limit. [Commit](https://github.com/open-webui/open-webui/commit/f8ec63203c4408c46bb06698ae624d17b01b9301), [Commit](https://github.com/open-webui/open-webui/commit/d3676b4f71bfdbaf4e4d76943c51117e18932ccf), [#25869](https://github.com/open-webui/open-webui/pull/25869)
- 🖼️ **OAuth profile pictures are validated.** Profile picture URLs from OAuth providers are now validated and their type checked when stored, preventing unsafe image sources. [Commit](https://github.com/open-webui/open-webui/commit/eb53281c9acb3660e09554a8dbde0a0b42646b70), [#24548](https://github.com/open-webui/open-webui/pull/24548)
- 📦 **Security updates to frontend dependencies.** Several frontend dependencies were updated to patch known security vulnerabilities. [#26281](https://github.com/open-webui/open-webui/pull/26281)
- 🤝 **Chat sharing respects the user-sharing permission.** The share-chat dialog now hides the option to share with specific users from people who lack that permission, matching the access rules enforced elsewhere. [#25915](https://github.com/open-webui/open-webui/pull/25915)
- 📤 **Chat export respects its permission everywhere.** Every chat export menu now checks the export permission, so users without it can no longer export chats through one of the dropdown menus. [#25914](https://github.com/open-webui/open-webui/pull/25914)
- 📂 **File write access requires real ownership.** Editing or deleting a file through a knowledge base or workspace model now requires that the object's owner actually owns the file, so a read-only file can no longer gain write access by being referenced from an object you control. [#26032](https://github.com/open-webui/open-webui/pull/26032)
- 🖌️ **Image edit endpoint enforces permission.** The image-edit endpoint now checks the image-edit switch and the image-generation permission, matching image generation, so it can't be called by users who lack access. [#26009](https://github.com/open-webui/open-webui/pull/26009)
- 📁 **Folder permission enforced on all folder actions.** Every folder operation now checks the folders permission, so the setting is respected consistently instead of only when listing folders. [Commit](https://github.com/open-webui/open-webui/commit/19a176fd36bea15c49d7f2d1539b4832e57a8bc2)
- 🧩 **Code Execution settings collapse when off.** The Code Execution settings section now collapses when the toggle is disabled, keeping the settings page tidy. [#25970](https://github.com/open-webui/open-webui/pull/25970)
- 📅 **German date format in Notes.** Dates in the Notes view now display correctly for German, where they previously failed to render. [#25985](https://github.com/open-webui/open-webui/pull/25985)
- 🎙️ **ElevenLabs speech keeps working when voices can't load.** Text-to-speech through ElevenLabs no longer fails when the available-voice list can't be fetched, instead of rejecting every voice. [Commit](https://github.com/open-webui/open-webui/commit/bb1419328b11b801b4c939dfc112700ba6f6fdab), [#26075](https://github.com/open-webui/open-webui/issues/26075)
- 🪟 **Default Permissions modal resets on close.** Closing the Default Permissions dialog without saving now discards unsaved edits instead of keeping them around the next time you open it. [Commit](https://github.com/open-webui/open-webui/commit/78a5015846a9e55ff2bc9d6cc98f880437abe8ed)
- 👯 **Side-by-side chat with the same model.** Running two panes with the same model no longer leaves one pane stuck waiting or showing the other pane's reply after a reload, since each pane's messages are now tracked separately. [Commit](https://github.com/open-webui/open-webui/commit/56ae99e96a845289b5787d2dd26a3d828f2295e7), [#25982](https://github.com/open-webui/open-webui/issues/25982)
- 💾 **Model edits no longer lost when changing access.** Adjusting a model's access no longer auto-saves on its own and discards your other unsaved changes to that model. [#26004](https://github.com/open-webui/open-webui/pull/26004)
- 🔧 **Parallel tool calls over the Anthropic-compatible API.** External Anthropic-compatible clients calling Open WebUI's messages endpoint now receive tool calls reliably when a model issues several at once or returns them in its final message. [Commit](https://github.com/open-webui/open-webui/commit/4210cae68e30173d7902582d32128dd699d5628a), [#25963](https://github.com/open-webui/open-webui/pull/25963), [#25964](https://github.com/open-webui/open-webui/discussions/25964)
- 🗃️ **Prompt caching preserved over the Anthropic-compatible API.** Requests through the Anthropic-compatible API now keep their prompt-caching markers instead of having them stripped, so clients that rely on caching work as intended. [Commit](https://github.com/open-webui/open-webui/commit/caedcbae4988ef59ea7052b2a3198e2da4b5291a), [#25998](https://github.com/open-webui/open-webui/pull/25998), [#25964](https://github.com/open-webui/open-webui/discussions/25964)
- 🔁 **Fewer redundant data loads.** Several views no longer fire duplicate background fetches at once, avoiding occasional glitches from overlapping requests. [#25943](https://github.com/open-webui/open-webui/pull/25943), [#25942](https://github.com/open-webui/open-webui/pull/25942), [#25934](https://github.com/open-webui/open-webui/pull/25934), [#25935](https://github.com/open-webui/open-webui/pull/25935), [#25838](https://github.com/open-webui/open-webui/pull/25838), [Commit](https://github.com/open-webui/open-webui/commit/e8d55c0a8beac9de0b2a0fe90f0bc0f9b64c1c1f)
- 🔎 **Steadier search boxes across admin and workspace.** Search fields for users, knowledge, prompts, tools, and similar lists now run only as you type and reset to the first page correctly, instead of occasionally re-searching on their own. [Commit](https://github.com/open-webui/open-webui/commit/fc9c2ea1915accd1f6edca467e965283dff71cd7), [#25938](https://github.com/open-webui/open-webui/pull/25938)
- 📊 **Admin feedback list loads again on PostgreSQL.** The admin feedback list no longer fails to load on PostgreSQL setups, where it previously returned a server error. [Commit](https://github.com/open-webui/open-webui/commit/7ee75a0c04a31528954903e88c9213d5fbb31aa7), [#25953](https://github.com/open-webui/open-webui/issues/25953)
- 🗂️ **Deleting nested folders checks chats correctly.** Deleting a folder that contains subfolders now accounts for the chats inside those subfolders when applying the delete-permission check, instead of only the top-level folder's chats. [Commit](https://github.com/open-webui/open-webui/commit/232421f40b84590e6d6fdecab4e43274aac37add), [#25920](https://github.com/open-webui/open-webui/issues/25920)
- 🖱️ **Dragging chats into folders is more reliable.** Dragging a chat into a folder no longer throws an error in cases where the chat couldn't be resolved. [#25928](https://github.com/open-webui/open-webui/pull/25928)
- 🛠️ **Workspace menu shows for the skills permission.** Users who only have the skills permission now see the Workspace entry in their menu, which previously appeared only for other workspace permissions. [#25925](https://github.com/open-webui/open-webui/pull/25925)
- 🧠 **Admins can always reach memories.** Administrators can now use the memories endpoints regardless of the memories permission toggle, matching how admin access works for other features. [#25924](https://github.com/open-webui/open-webui/pull/25924)
- 🖼️ **Image settings page survives a config load failure.** The admin image settings page no longer crashes when its configuration fails to load, showing the page instead. [#25933](https://github.com/open-webui/open-webui/pull/25933)
- 🧵 **Code blocks render in channel threads.** Code blocks now display correctly in a channel's thread view, where duplicated message identifiers previously broke their rendering. [Commit](https://github.com/open-webui/open-webui/commit/7d1f9415807a47e0da4f862327e9802a3b839753), [#25917](https://github.com/open-webui/open-webui/pull/25917)
- 🔵 **No more false unread badges on chats.** Chats no longer show an unread indicator after automatic changes like title generation or pinning, archiving, and moving them between folders, and newly created chats are marked read correctly so they don't appear unread after a refresh. [#25912](https://github.com/open-webui/open-webui/pull/25912), [#25782](https://github.com/open-webui/open-webui/pull/25782), [#25108](https://github.com/open-webui/open-webui/issues/25108)
- 📌 **Pinned notes stay in sync.** Pinning, unpinning, or deleting a note now updates the sidebar's pinned list consistently, instead of showing a stale pin state. [#25918](https://github.com/open-webui/open-webui/pull/25918), [#25640](https://github.com/open-webui/open-webui/pull/25640)
- 📅 **All-day calendar events keep their date.** Saving an all-day calendar event no longer shifts it by a day for users in certain time zones. [#25864](https://github.com/open-webui/open-webui/pull/25864)
- 🧷 **Damaged chat history recovers more reliably.** When a chat's current position is missing or points at a malformed message, Open WebUI now repairs it from the latest valid message — on both the client and the server — instead of risking a broken history view. [Commit](https://github.com/open-webui/open-webui/commit/2308b59f135e4c2da11eabdf2306e55a5dd4e9fb), [Commit](https://github.com/open-webui/open-webui/commit/a146e17bdcaf94fee3a98aa36b4f401e1f06c1d4), [#26298](https://github.com/open-webui/open-webui/pull/26298), [#26258](https://github.com/open-webui/open-webui/pull/26258), [#26257](https://github.com/open-webui/open-webui/issues/26257)
- 💾 **Saving a chat no longer drops messages.** Chat updates are now merged with the existing history on the server, with explicit tracking of deleted messages, instead of overwriting it, preventing message loss from concurrent or partial saves. [Commit](https://github.com/open-webui/open-webui/commit/22a44e67a8ba781feb8f2a267fed0c40213d8432), [Commit](https://github.com/open-webui/open-webui/commit/3319b6410e1b600b7a885a5fb78573e9a2061c22), [Commit](https://github.com/open-webui/open-webui/commit/24b8619f64731788ac38813768abbb64405effa4), [#25657](https://github.com/open-webui/open-webui/pull/25657)
- 📺 **Channel message updates stay in their channel.** Streaming updates to a channel message are now skipped if the message no longer exists or belongs to a different channel, preventing stray updates. [Commit](https://github.com/open-webui/open-webui/commit/ac3449cac91e62b08a7c28e54fcd044d14dea791)
- 📌 **Pinned channel messages update for everyone.** Pinning or unpinning a channel message now updates live for all members and works from thread views, instead of only changing for the person who pinned it. [Commit](https://github.com/open-webui/open-webui/commit/7ea7680f563da30b121258e5a7d7123185c4da2a)
- 📄 **Mistral OCR uploads work again.** Document OCR through Mistral has been repaired after an upstream library change broke its file uploads. [#25779](https://github.com/open-webui/open-webui/pull/25779)
- 🗂️ **Chroma collection detection fixed.** Open WebUI now correctly detects existing Chroma collections, fixing a case where it always reported them as missing. [#25780](https://github.com/open-webui/open-webui/pull/25780)
- 📊 **Vega-Lite charts render reliably.** Vega-Lite charts in chat are now detected by their code block language tag, so they render correctly. [#25843](https://github.com/open-webui/open-webui/pull/25843)
- 🏷️ **Long chat tag lists scroll.** The tags section in the chat menu now scrolls instead of overflowing when a chat has many tags. [#26031](https://github.com/open-webui/open-webui/pull/26031)
- ⌨️ **Enter key shows correctly on iOS.** The Enter key symbol in the keyboard shortcuts list no longer renders as an emoji on iOS. [#26173](https://github.com/open-webui/open-webui/pull/26173)
- 🔗 **Whitespace in names no longer breaks MCP connections.** User name and info headers are now trimmed before being forwarded, fixing MCP connection failures when a display name contained leading or trailing whitespace. [#26182](https://github.com/open-webui/open-webui/pull/26182), [#26181](https://github.com/open-webui/open-webui/issues/26181)
- 🈳 **Search no longer fires mid-composition.** Typing in search with an input method editor (such as Japanese, Chinese, or Korean) no longer triggers a search when you press Enter to confirm a composition. [#26238](https://github.com/open-webui/open-webui/pull/26238), [#26285](https://github.com/open-webui/open-webui/pull/26285), [#26172](https://github.com/open-webui/open-webui/issues/26172)
- 🧰 **Valves icon stays visible.** The icon for configuring valves no longer disappears, so user-configurable tool and function settings remain reachable. [#26256](https://github.com/open-webui/open-webui/pull/26256)
- 🎛️ **Chat controls persist across navigation.** Edits to chat controls are now kept when navigating between chats, and reverting a control to the chat's saved value persists correctly, instead of being lost. [#26336](https://github.com/open-webui/open-webui/pull/26336), [#25793](https://github.com/open-webui/open-webui/pull/25793)
- 🔍 **Chat search tool handles empty queries.** The built-in chat search tool no longer crashes when called with an empty query. [Commit](https://github.com/open-webui/open-webui/commit/b854eb09b13216f914ce5fd07ab717b8f752882b), [#26310](https://github.com/open-webui/open-webui/issues/26310)
- 📑 **More robust MinerU document processing.** Document processing through MinerU now handles its ZIP results more safely, including very large outputs. [Commit](https://github.com/open-webui/open-webui/commit/23d03d6aaebcced6c1e39e98dfff76ab73df8804), [#26263](https://github.com/open-webui/open-webui/pull/26263)
- ⏰ **Scheduled automations with session-auth tools work.** Automations that use session-authenticated tools or terminals now authenticate correctly when running on a schedule, instead of failing. [Commit](https://github.com/open-webui/open-webui/commit/5b1c42e81a3ef3ad5ce5852dbf84020cb5e2498c), [#26247](https://github.com/open-webui/open-webui/pull/26247), [#26137](https://github.com/open-webui/open-webui/issues/26137)
- 📝 **Model system prompt preserved with knowledge.** A model's system prompt is no longer dropped when knowledge retrieval runs with native tool calling. [Commit](https://github.com/open-webui/open-webui/commit/cfb49c4c181a96d5df07fbfacd819639baef0bab), [#26217](https://github.com/open-webui/open-webui/pull/26217)
- 🔑 **Expired sessions return you to sign-in.** When a request fails because your session has expired, Open WebUI now redirects you to the sign-in page instead of leaving you on a broken view. [Commit](https://github.com/open-webui/open-webui/commit/5922727402593900758d84004f950071c701f6de), [#26237](https://github.com/open-webui/open-webui/pull/26237)
- 🎯 **Ejecting a workspace model unloads the right model.** Unloading a workspace model now resolves to its underlying base model, so the correct model is freed from memory. [Commit](https://github.com/open-webui/open-webui/commit/464e703e4716812d015966582152ddd8a2c71572), [#26269](https://github.com/open-webui/open-webui/pull/26269)
- 🔄 **Edited models refresh in the admin list.** After editing a model in the admin settings, the models list now updates right away instead of needing a manual reload. [Commit](https://github.com/open-webui/open-webui/commit/b34d6c836ee43d0e9721fa4fd6457d934e3e2a17)
- 🗂️ **Workspace model bulk actions and search work across pages.** Bulk actions on workspace models now apply across all of them, and search results paginate correctly. [#26274](https://github.com/open-webui/open-webui/pull/26274)
- 🧩 **MCP resource results come through.** Tool results that return resource content — including binary blobs and URI references — are no longer silently dropped, and image results are attached as files. [#25260](https://github.com/open-webui/open-webui/pull/25260), [#24038](https://github.com/open-webui/open-webui/issues/24038), [Commit](https://github.com/open-webui/open-webui/commit/783205a965c556815fae84b64d74f26a2e5e5729)
- 🔗 **Broader MCP server compatibility for OAuth.** Open WebUI now discovers an MCP server's protected resource metadata even when the server doesn't advertise it, and recognizes more OAuth preflight variations, so more MCP servers connect. [#25980](https://github.com/open-webui/open-webui/pull/25980), [#25954](https://github.com/open-webui/open-webui/issues/25954), [Commit](https://github.com/open-webui/open-webui/commit/45fea34bd0c8ce54b0822499c40e3e6964220354), [#26068](https://github.com/open-webui/open-webui/pull/26068)
- 📤 **Clearer upload error messages.** Failed uploads now show a readable explanation instead of an opaque error stub. [#25961](https://github.com/open-webui/open-webui/pull/25961)
- 📋 **Cloned prompts get a proper title.** Cloning a prompt now adds the clone suffix to the correct field, so the duplicate is named as expected. [#25800](https://github.com/open-webui/open-webui/pull/25800)
- 📐 **Long default group names don't overflow.** A long default group name no longer overflows its row in the admin authentication settings. [#25685](https://github.com/open-webui/open-webui/pull/25685)
- 🖐️ **Sidebar drags don't trigger uploads.** Dragging a chat item in the sidebar no longer shows the file-upload overlay. [#25675](https://github.com/open-webui/open-webui/pull/25675)
- 🔁 **Recovers from a stuck streaming response.** If the signal that a response finished is missed — for example after a mobile app is backgrounded mid-stream — Open WebUI now recovers the chat instead of leaving it stuck in a streaming state. [Commit](https://github.com/open-webui/open-webui/commit/aa851d93c63e7da6e94292d0b7586674339d47e5), [Commit](https://github.com/open-webui/open-webui/commit/edf2c6c8f76e7f6a5917e991f371a604adc34c5f), [Commit](https://github.com/open-webui/open-webui/commit/2856def6c05b2fb8c55b4e7170f05db0c4f956f1), [#26320](https://github.com/open-webui/open-webui/pull/26320), [#26315](https://github.com/open-webui/open-webui/issues/26315)
- 🧠 **Model skills load on demand instead of filling the prompt.** A model's attached skills are now presented to the model as a manifest it can load when needed, rather than having their full content inserted into the system prompt; skills you mention inline still get their content included directly. [Commit](https://github.com/open-webui/open-webui/commit/e6d35fc4cca4f4b1e5cad97d7b7e3089ef832018), [Commit](https://github.com/open-webui/open-webui/commit/44b9463498085741669e6f5d92e21b5ecc5fd795), [#25592](https://github.com/open-webui/open-webui/issues/25592), [#25599](https://github.com/open-webui/open-webui/pull/25599)
- 🗂️ **Empty metadata no longer breaks Chroma indexing.** Document metadata with empty values is now filtered out before indexing, fixing a case that could fail on Chroma. [Commit](https://github.com/open-webui/open-webui/commit/118549caf3), [#26342](https://github.com/open-webui/open-webui/pull/26342), [#26339](https://github.com/open-webui/open-webui/issues/26339)
- 🔁 **Updating a knowledge file won't break the knowledge base.** When a file's content is updated, its new embeddings are now added before the old ones are removed, so a failed reindex leaves the knowledge base intact and usable instead of empty. [Commit](https://github.com/open-webui/open-webui/commit/248315de14d4537e0f2ec3f94dee8a7334cad248), [#23789](https://github.com/open-webui/open-webui/pull/23789), [#23787](https://github.com/open-webui/open-webui/issues/23787)
- 🔤 **Documents with special tokens index correctly.** Measuring chunk sizes no longer fails when a document contains text that looks like a special token. [#26210](https://github.com/open-webui/open-webui/pull/26210)
- 📝 **Note file attachments stay in sync.** Updating the files attached to a note now keeps the editor and saved note in sync. [Commit](https://github.com/open-webui/open-webui/commit/5055fb85aa8c8d5ef785daea7438498e36ddf33f)
- 📱 **Better banner layout on mobile.** Notification banners now lay out correctly on small screens. [Commit](https://github.com/open-webui/open-webui/commit/4ed45ce84394c435405f93d03f07dabd797cdec3), [#24912](https://github.com/open-webui/open-webui/pull/24912)
- 📂 **Knowledge file listing includes attached files.** Listing files through the knowledge tools now also shows files attached directly to a model, not only those inside a knowledge base, fixing cases where listing returned no results for a model with a single attached file. [Commit](https://github.com/open-webui/open-webui/commit/40b655e99e2c6dd802654ec0cdac38a4bcda08b3), [#26301](https://github.com/open-webui/open-webui/issues/26301)
- 🏷️ **Chat titles generate after long first responses.** A new chat now gets its title even when the first response takes a long time, such as one with extensive reasoning or many tool calls, instead of staying "New Chat". [Commit](https://github.com/open-webui/open-webui/commit/754787f43dffad3dce2c90e4fd0417b1f9dbb3c0), [#26240](https://github.com/open-webui/open-webui/issues/26240)
- 🔌 **Cancelling an MCP request no longer errors.** Stopping a response that was using MCP tools now shuts the connection down cleanly instead of surfacing a server error. [Commit](https://github.com/open-webui/open-webui/commit/ff5cec43bd360829cfdcc6a5253d1ba63f236b7f)
- 🧠 **Reasoning details preserved across turns.** Models that return structured or encrypted reasoning data, such as Gemini, no longer have their assistant message split mid-stream, keeping reasoning continuity across turns. [Commit](https://github.com/open-webui/open-webui/commit/75db531c1238af113bb2b211882713e5e2f459cf), [#23852](https://github.com/open-webui/open-webui/pull/23852)
- 📡 **Error messages show for non-standard streaming responses.** Providers that send errors over non-standard server-sent events now surface a readable error instead of nothing. [#23228](https://github.com/open-webui/open-webui/pull/23228)
- 🔑 **Whitespace in terminal server keys no longer breaks auth.** Terminal server API keys are now trimmed before use, so a key with stray leading or trailing whitespace still authenticates. [Commit](https://github.com/open-webui/open-webui/commit/fe3300bd6581aa469c2cdf757700ecc65a200df4), [Commit](https://github.com/open-webui/open-webui/commit/d6cda4a04b2e3a48855fc91abb2376cfd3a0378d)
- 🔥 **One bad URL no longer fails Firecrawl scraping.** When fetching multiple pages through Firecrawl, a single failing URL is now skipped instead of aborting the whole batch, and rate limits are respected between requests. [Commit](https://github.com/open-webui/open-webui/commit/6f8221df58b17334233ac6bfe069b8f837f677d6), [#24183](https://github.com/open-webui/open-webui/pull/24183)
- 📱 **Usable chat input on mobile with many tools.** When skills, tools, terminal, web search, and image generation buttons fill the chat input, the row of buttons now scrolls horizontally while the menu, voice, and send controls stay reachable, instead of pushing them off-screen. [Commit](https://github.com/open-webui/open-webui/commit/6f8221df58b17334233ac6bfe069b8f837f677d6), [#26142](https://github.com/open-webui/open-webui/issues/26142)
- 👤 **Owner avatars only show on shared folders.** Chat owner avatars in a folder's chat list now appear only when the folder is actually shared, instead of showing whenever owner information happened to be present. [Commit](https://github.com/open-webui/open-webui/commit/9802b0d13563b3535b86a350bab000d84686b1e9)
- 📜 **No stray scrollbar on the About page.** Extra spacing that caused an unnecessary scrollbar on the About settings page has been removed. [#25802](https://github.com/open-webui/open-webui/pull/25802)
- 🚪 **Sign out works from the Account Pending page.** Signing out while your account is pending now goes through the proper sign-out flow, so single sign-on sessions are ended and you are no longer left stuck on the pending screen. [#25681](https://github.com/open-webui/open-webui/pull/25681), [#25644](https://github.com/open-webui/open-webui/issues/25644)
- 🔢 **Built-in tools accept numeric arguments.** Built-in tools no longer crash when a model passes a number or a string where a specific scalar type is expected; values are now coerced to the declared type. [Commit](https://github.com/open-webui/open-webui/commit/c4688b958d7f7929f5f4303493ca311c2c121683), [#25638](https://github.com/open-webui/open-webui/pull/25638), [#25731](https://github.com/open-webui/open-webui/pull/25731), [#25641](https://github.com/open-webui/open-webui/issues/25641)
- ⏱️ **MinerU timeout saves.** The MinerU API timeout can now be saved from the admin settings, accepting a numeric value. [Commit](https://github.com/open-webui/open-webui/commit/3fd0384ffcd0eddd6f4c688475f8ad5d3b4de510), [#25604](https://github.com/open-webui/open-webui/pull/25604), [#25603](https://github.com/open-webui/open-webui/issues/25603)
- 🔧 **Background completion no longer clears active tasks.** Finishing a chat in the background no longer wipes the set of active tasks, fixing a case where ongoing task indicators could be lost. [Commit](https://github.com/open-webui/open-webui/commit/388f62f8a002b789887d016892a1bf152c9d90af), [#25217](https://github.com/open-webui/open-webui/issues/25217)
- 👁️ **Workspace base model selector respects visibility.** The base model selector in the workspace now hides models you don't have access to, matching their visibility settings. [#25668](https://github.com/open-webui/open-webui/pull/25668)
- 🧵 **Channel threads bind to the right channel.** A channel thread's parent and replies are now tied to the channel in the URL, preventing mismatches when switching channels. [#25766](https://github.com/open-webui/open-webui/pull/25766)
- 🗑️ **Unsharing cleans up orphaned rows.** Unsharing a chat now handles leftover shared-chat records, avoiding stale entries. [#25632](https://github.com/open-webui/open-webui/pull/25632)
- 🔎 **Web search results reach the model with retrieval on.** Web search results are now passed to the model even when embedding and retrieval are enabled, instead of being left out. [#25600](https://github.com/open-webui/open-webui/pull/25600)
- 🔢 **Group count follows search.** The groups count now reflects the filtered search results instead of the full list. [#25689](https://github.com/open-webui/open-webui/pull/25689)
- ␣ **Space key works when renaming.** Pressing space while renaming a file or folder no longer opens it, so spaces can be typed in names. [#25627](https://github.com/open-webui/open-webui/pull/25627)
- 🩹 **Missing local embedding model no longer blocks startup.** A missing local embedding model now surfaces as a deferred error instead of preventing the server from starting. [#25683](https://github.com/open-webui/open-webui/pull/25683)
- 🔤 **Consistent settings label capitalization.** Toggle labels in settings now use consistent title casing. [#25765](https://github.com/open-webui/open-webui/pull/25765)
- ♿ **Better screen-reader labels on toggles.** Integration and switch toggles now expose proper accessibility labels and pressed state for screen readers. [#25258](https://github.com/open-webui/open-webui/pull/25258), [#25230](https://github.com/open-webui/open-webui/pull/25230)
- 📜 **Long dropdowns scroll.** Dropdown selects now scroll when their list is long, so all options stay reachable. [Commit](https://github.com/open-webui/open-webui/commit/4bc463072185d0d7c1c9218cd4487501090eccfe), [#25608](https://github.com/open-webui/open-webui/pull/25608)
- 🔽 **Collapsible sections don't misfire on load.** Collapsible sections no longer trigger their change action when first rendered, avoiding unintended toggles on page load. [Commit](https://github.com/open-webui/open-webui/commit/c93d4f04aad1b0d4f8a8bda7ac403b2c8ee35f38), [#25229](https://github.com/open-webui/open-webui/pull/25229)
- ➗ **Large math expressions no longer crash rendering.** Parsing math delimiters no longer overflows on very large or deeply nested input, so messages with heavy math render instead of failing. [#25845](https://github.com/open-webui/open-webui/pull/25845)
- 🗄️ **Oversized chunks no longer break Milvus indexing.** Overly long text chunks are now trimmed before being sent to Milvus, so a single large chunk can no longer fail the whole batch and leave a file with no embeddings. [#25857](https://github.com/open-webui/open-webui/pull/25857), [#25858](https://github.com/open-webui/open-webui/pull/25858)
- 📝 **Code editor stays open when empty.** The code editor drawer no longer collapses when its content is empty. [#25855](https://github.com/open-webui/open-webui/pull/25855)
- 💽 **Settings no longer lost after a restart.** Admin configuration is now stored more reliably, fixing cases where external connections and model parameters could be lost after restarting the server. [Commit](https://github.com/open-webui/open-webui/commit/5cdcdbaeec9fc8156721c38c33ec37956962871c), [Commit](https://github.com/open-webui/open-webui/commit/21f9e5295bf484169d72f4538f7c926b5519723c), [Commit](https://github.com/open-webui/open-webui/commit/8958b64b5a7e96cd8c2260571b54324ca3bfe127), [#24743](https://github.com/open-webui/open-webui/issues/24743), [#25911](https://github.com/open-webui/open-webui/pull/25911), [#25959](https://github.com/open-webui/open-webui/pull/25959)
- 📜 **Visible chat scrollbar.** The chat area now shows a scrollbar, making it easier to scroll through long responses. [Commit](https://github.com/open-webui/open-webui/commit/d56e1cb0b9), [#25833](https://github.com/open-webui/open-webui/issues/25833)
- 🎚️ **Default model parameters apply to requests.** Default model parameters are now applied to outbound requests, so settings like temperature and the context window take effect as configured. [Commit](https://github.com/open-webui/open-webui/commit/cd6cc39c6d), [Commit](https://github.com/open-webui/open-webui/commit/19db873603215773f9a64e03785a1a076dc6c8a8), [#24930](https://github.com/open-webui/open-webui/issues/24930), [#26209](https://github.com/open-webui/open-webui/issues/26209)
- 🟢 **Ollama loaded-model indicator restored.** The indicator showing which Ollama model is loaded in VRAM works again after recent changes. [#25586](https://github.com/open-webui/open-webui/issues/25586), [#25732](https://github.com/open-webui/open-webui/issues/25732)
- 🪪 **Static MCP connectors recover missing OAuth details.** MCP connectors configured with static OAuth credentials now fill in a missing scope or resource from the server's published metadata, so they connect correctly instead of failing when those values were left out. [Commit](https://github.com/open-webui/open-webui/commit/88901bfa041ddcceab1cd4a97f08f0b43835eb05), [#25898](https://github.com/open-webui/open-webui/issues/25898)
- 📊 **Token usage and cost stats no longer wiped by background tasks.** A response's token usage and cost are now preserved when background tasks like title, tag, and follow-up generation run on the same chat, instead of being overwritten. [Commit](https://github.com/open-webui/open-webui/commit/95391221dfabbfcd9090ab472b1c02a4c75c0387)
- 🔗 **Model share link updated.** Sharing a model now opens the current community post page, fixing the link that pointed at the old endpoint. [#25801](https://github.com/open-webui/open-webui/pull/25801)
### Changed
- ⚠️ **Database Migrations**: This update contains database migrations. Please be sure to back up your database before updating, as downgrading after the migration is not supported.
- 🔔 **System events now fire automatically.** With the new event system, Open WebUI emits events for activity like startup, sign-ins, and configuration changes, so any webhook you already have configured may begin receiving calls for these newly emitted events after upgrading. Review your event and webhook settings after updating so you only receive the events you want. [Commit](https://github.com/open-webui/open-webui/commit/b5c43968db0ea1556b228d143ae5946dc4e944ba)
- 🔀 **Native tool calling is now the default.** Every chat and model that had not explicitly chosen a tool-calling mode now runs Native, which relies on a model's built-in tool support, while the old behavior has been renamed "Legacy" and made the explicit opt-out; if your models depend on the previous approach you must switch them back to "Legacy" per chat, per model, or globally in your default model parameters to preserve their behavior. [Commit](https://github.com/open-webui/open-webui/commit/b1d40f340921c27eb9a965b9feeb2563856e25e2)
- 🗂️ **Authentication settings moved to their own page.** LDAP, OAuth, and related authentication settings have moved out of the General settings page into a dedicated Authentication page in the admin panel. [Commit](https://github.com/open-webui/open-webui/commit/5cdcdbaeec9fc8156721c38c33ec37956962871c)
- 🎓 **Several features are no longer beta.** Memories, Notes, Channels, and High Contrast Mode have graduated out of beta and no longer carry a beta label. [Commit](https://github.com/open-webui/open-webui/commit/7b55a63fc7ee323e9114713ce1d2f3f688aa37e6)
- 🔧 **Local web fetch setting renamed.** The "ENABLE_RAG_LOCAL_WEB_FETCH" environment variable is now "ENABLE_LOCAL_WEB_FETCH", reflecting that it applies beyond retrieval; the old name still works as a deprecated alias. [Commit](https://github.com/open-webui/open-webui/commit/e3ba6984534898695b47ee4fc3d6b746e2865abc)
- 🔧 **You.com search key renamed.** You.com web search now prefers the "YDC_API_KEY" environment variable, with the previous "YOUCOM_API_KEY" still accepted as a fallback. [Commit](https://github.com/open-webui/open-webui/commit/df634bb64f5043b0292e43c69bd1d31676c89328), [#26316](https://github.com/open-webui/open-webui/pull/26316)
- 🧪 **Client-side Python now runs sandboxed.** Client-side Python (Pyodide) now runs in a sandboxed, opaque-origin iframe by default, isolating executed code from your session, cookies, local storage, and the app's own endpoints, while full Python, JavaScript, and external network access keep working. Code that relied on reaching same-origin Open WebUI endpoints from Pyodide will no longer be able to, and Pyodide is now marked legacy in the admin Code Execution settings. [Commit](https://github.com/open-webui/open-webui/commit/516051304e1b1f250c34438746ade673a79bd40c), [Commit](https://github.com/open-webui/open-webui/commit/c7be66626fd10c75ec35f662a709129ba1b020ec), [Commit](https://github.com/open-webui/open-webui/commit/62ae2069183109d878d72b9444a0e7c4f6c66caa), [Commit](https://github.com/open-webui/open-webui/commit/518702caae5a6484e71aa79e8ab908ec398290a7), [Commit](https://github.com/open-webui/open-webui/commit/03a8363583b7e0e04760d49f1e8d28dbbfefee4d)
## [0.9.6] - 2026-06-01
### Added

View file

@ -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,7 +32,6 @@ 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

View file

@ -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 curl jq ca-certificates \
git build-essential pandoc gcc netcat-openbsd curl jq \
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', download_dir='/usr/local/share/nltk_data')"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
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,18 +158,12 @@ 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', download_dir='/usr/local/share/nltk_data')"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
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 && \
@ -190,17 +184,6 @@ COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json
# copy backend files
COPY --chown=$UID:$GID ./backend .
# The backend rewrites its bundled static assets (favicons, splash, manifest,
# loader.js, ...) under open_webui/static at startup. Make that directory
# writable by an arbitrary UID -- which under OpenShift's restricted SCC is
# always a member of GID 0 -- so those writes don't fail with EACCES and crash
# the boot log with "[Errno 13] Permission denied". `chmod -R g=u` mirrors the
# owner bits onto the group (the Red Hat arbitrary-UID idiom). This is applied
# unconditionally because it targets a directory the app writes on every start;
# the broader, opt-in USE_PERMISSION_HARDENING below covers the rest of /app.
RUN chgrp -R 0 /app/backend/open_webui/static && \
chmod -R g=u /app/backend/open_webui/static
EXPOSE 8080
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1

View file

@ -8,7 +8,7 @@
![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui)
![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red)
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/open-webui)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
![Open WebUI Banner](./banner.png)
@ -27,82 +27,58 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
## Key Features of Open WebUI ⭐
- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments.
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely.
- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**.
- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group.
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more.
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/).
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection.
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control.
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
- **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready.
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced.
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost.
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI).
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command.
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation.
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed.
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing.
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
- ⚙️ **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards.
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
- 🗄️ **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace.
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage.
- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack.
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers.
- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage!
- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features.
- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security).
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
## The Open WebUI Ecosystem 🌐
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.
- 🖥️ **Native Desktop App** ([open-webui/desktop](https://github.com/open-webui/desktop)): Run Open WebUI as a native app on macOS, Windows, and Linux. System-wide Spotlight chat bar with screenshot capture, push-to-talk voice, and optional fully-local inference via a built-in llama.cpp engine.
Want to learn more? Check out our [Open WebUI documentation](https://docs.openwebui.com) for more details!
---
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
@ -246,10 +222,6 @@ This project contains code under multiple licenses. The current codebase include
If you have any questions, suggestions, or need assistance, please open an issue or join our
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
## Security 🛡️
If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure!
## Star History
<a href="https://star-history.com/#open-webui/open-webui&Date">

View file

@ -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:-*}" --ws-per-message-deflate "${UVICORN_WS_PER_MESSAGE_DEFLATE:-true}" --reload
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload

View file

@ -11,16 +11,12 @@ import uvicorn
app = typer.Typer()
KEY_FILE = Path.cwd() / '.webui_secret_key'
DEFAULT_SECRET_KEY_LENGTH = 24
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()
@ -41,11 +37,8 @@ def serve(
if os.getenv('WEBUI_SECRET_KEY') is None:
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
if not KEY_FILE.exists():
key_length = int(os.getenv('WEBUI_SECRET_KEY_LENGTH', DEFAULT_SECRET_KEY_LENGTH))
if key_length < 1:
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
@ -74,7 +67,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, UVICORN_WS_PER_MESSAGE_DEFLATE
from open_webui.env import UVICORN_WORKERS # Import the workers setting
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
# which is incompatible with psycopg v3 async. Setting loop='none' lets
@ -87,7 +80,6 @@ def serve(
port=port,
forwarded_allow_ips='*',
workers=UVICORN_WORKERS,
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
loop=loop,
)
@ -98,15 +90,12 @@ 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,
)

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,8 @@
from __future__ import annotations
import errno
from enum import Enum
_ERRNO_MESSAGES = {
errno.ENAMETOOLONG: 'File name is too long.',
errno.ENOSPC: 'The server is out of storage space.',
errno.EDQUOT: 'Server storage quota exceeded.',
errno.EACCES: 'Server storage is not writable.',
errno.EPERM: 'Server storage is not writable.',
errno.EROFS: 'Server storage is not writable.',
}
def _error_message(err='', fallback='') -> str:
if not err:
return 'Something went wrong :/'
if isinstance(err, OSError) and err.errno in _ERRNO_MESSAGES:
return f'[ERROR: {_ERRNO_MESSAGES[err.errno]}]'
if isinstance(err, Exception):
return f'[ERROR: {fallback}]' if fallback else 'Something went wrong :/'
return f'[ERROR: {err}]'
class MESSAGES(str, Enum):
DEFAULT = lambda msg='': f'{msg if msg else ""}'
MODEL_ADDED = lambda model='': f"The model '{model}' has been added successfully."
@ -39,7 +18,7 @@ class ERROR_MESSAGES(str, Enum):
def __str__(self) -> str:
return super().__str__()
DEFAULT = _error_message
DEFAULT = lambda err='': f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
ENV_VAR_NOT_FOUND = 'Required environment variable not found. Terminating now.'
CREATE_USER_ERROR = 'Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance.'
DELETE_USER_ERROR = 'Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot.'
@ -99,7 +78,7 @@ class ERROR_MESSAGES(str, Enum):
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
WEB_SEARCH_ERROR = 'Something went wrong while searching the web.'
WEB_SEARCH_ERROR = lambda err='': err if err else 'Something went wrong while searching the web.'
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
@ -118,15 +97,9 @@ 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"

View file

@ -102,7 +102,6 @@ class JSONFormatter(logging.Formatter):
LOG_FORMAT = os.getenv('LOG_FORMAT', '').lower()
LOGURU_DIAGNOSE = os.getenv('LOGURU_DIAGNOSE', 'False').lower() == 'true'
GLOBAL_LOG_LEVEL = os.getenv('GLOBAL_LOG_LEVEL', '').upper()
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
@ -150,11 +149,6 @@ INSTANCE_ID = os.getenv('INSTANCE_ID', str(uuid4()))
ENABLE_DB_MIGRATIONS = os.getenv('ENABLE_DB_MIGRATIONS', 'True').lower() == 'true'
# Swap the JSON encoder/decoder used across the app (HTTP request bodies, JSONResponse
# bodies, upstream provider responses, socket.io payloads) from the stdlib `json` module
# to orjson. Faster, but stricter: see open_webui/utils/json_codec.py for the differences.
ENABLE_ORJSON = os.getenv('ENABLE_ORJSON', 'False').lower() == 'true'
# Function to parse each section
def parse_section(section):
@ -227,7 +221,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('Moving %s to %s', DATA_DIR, NEW_DATA_DIR)
log.info(f'Moving {DATA_DIR} to {NEW_DATA_DIR}')
for item in DATA_DIR.iterdir():
dest = NEW_DATA_DIR / item.name
if item.is_dir():
@ -297,7 +291,6 @@ if 'postgres://' in DATABASE_URL:
DATABASE_URL = DATABASE_URL.replace('postgres://', 'postgresql://')
DATABASE_SCHEMA = os.getenv('DATABASE_SCHEMA', None)
DATABASE_ENABLE_IAM_TOKEN_AUTH = os.getenv('DATABASE_ENABLE_IAM_TOKEN_AUTH', 'False').lower() == 'true'
_pool_size_raw = os.getenv('DATABASE_POOL_SIZE')
try:
@ -353,18 +346,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')
# 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_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
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,11 +369,6 @@ 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')
@ -400,12 +388,6 @@ try:
except ValueError:
REDIS_SOCKET_CONNECT_TIMEOUT = None
REDIS_SOCKET_TIMEOUT = os.getenv('REDIS_SOCKET_TIMEOUT', '')
try:
REDIS_SOCKET_TIMEOUT = float(REDIS_SOCKET_TIMEOUT)
except ValueError:
REDIS_SOCKET_TIMEOUT = None
# Whether to enable TCP SO_KEEPALIVE on Redis client sockets. Opt-in:
# defaults to off so behavior is unchanged for existing deployments. When
# enabled, the kernel sends TCP keepalive probes on idle connections so
@ -448,9 +430,6 @@ 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
####################################
@ -464,16 +443,17 @@ WEBSOCKET_REDIS_OPTIONS = os.getenv('WEBSOCKET_REDIS_OPTIONS', '')
if WEBSOCKET_REDIS_OPTIONS == '':
WEBSOCKET_REDIS_OPTIONS = {'socket_timeout': None}
if REDIS_SOCKET_CONNECT_TIMEOUT:
WEBSOCKET_REDIS_OPTIONS['socket_connect_timeout'] = REDIS_SOCKET_CONNECT_TIMEOUT
WEBSOCKET_REDIS_OPTIONS = {'socket_connect_timeout': REDIS_SOCKET_CONNECT_TIMEOUT}
else:
log.debug('No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None')
WEBSOCKET_REDIS_OPTIONS = None
else:
try:
WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)
WEBSOCKET_REDIS_OPTIONS.setdefault('socket_timeout', None)
except Exception:
log.warning('Invalid WEBSOCKET_REDIS_OPTIONS, defaulting to socket_timeout=None')
WEBSOCKET_REDIS_OPTIONS = {'socket_timeout': None}
log.warning('Invalid WEBSOCKET_REDIS_OPTIONS, defaulting to None')
WEBSOCKET_REDIS_OPTIONS = None
WEBSOCKET_REDIS_URL = os.getenv('WEBSOCKET_REDIS_URL', REDIS_URL)
WEBSOCKET_REDIS_CLUSTER = os.getenv('WEBSOCKET_REDIS_CLUSTER', str(REDIS_CLUSTER)).lower() == 'true'
@ -507,15 +487,6 @@ 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 == '':
@ -527,112 +498,20 @@ else:
WEBSOCKET_EVENT_CALLER_TIMEOUT = 300
import ssl as _ssl
# Dedicated env var for a custom CA bundle file path. When set, this is
# used as the default CA bundle for all outbound HTTPS connections that
# have SSL verification enabled (i.e. when their per-connection SSL env
# var is ``"True"``). Per-connection overrides (setting the SSL env var
# to a path directly) take precedence over this global fallback.
#
# This follows the industry convention of ``SSL_CERT_FILE`` / ``REQUESTS_CA_BUNDLE``
# but is scoped to Open WebUI to avoid interfering with system-level settings.
AIOHTTP_CLIENT_SSL_CERT_FILE = os.getenv('AIOHTTP_CLIENT_SSL_CERT_FILE', '').strip()
def _build_ssl_context_from_file(path: str) -> '_ssl.SSLContext | None':
"""Create an SSLContext from a CA bundle file, or None if invalid."""
if not path:
return None
if not os.path.isfile(path):
log.warning(
'SSL CA bundle path does not exist: %r, ignoring',
path,
)
return None
ctx = _ssl.create_default_context(cafile=path)
log.info('Using custom SSL CA bundle: %s', path)
return ctx
# Pre-built SSLContext from the dedicated env var (cached once at startup).
_GLOBAL_SSL_CONTEXT = _build_ssl_context_from_file(AIOHTTP_CLIENT_SSL_CERT_FILE)
def _parse_ssl_env(value: str) -> 'bool | _ssl.SSLContext':
"""Parse an SSL env var into a bool or SSLContext.
- ``"true"`` uses ``AIOHTTP_CLIENT_SSL_CERT_FILE`` context if set,
otherwise ``True`` (default SSL verification via certifi)
- ``"false"`` ``False`` (no verification)
- ``"/path/to/ca-bundle.crt"`` ``SSLContext`` loading that CA file
(takes precedence over ``AIOHTTP_CLIENT_SSL_CERT_FILE``)
This allows users with corporate or internal CAs to point Open WebUI
at a custom CA bundle without disabling verification entirely.
"""
lower = value.strip().lower()
if lower == 'true':
# Use the global dedicated CA bundle if configured, otherwise default
return _GLOBAL_SSL_CONTEXT if _GLOBAL_SSL_CONTEXT is not None else True
if lower == 'false':
return False
# Treat as a file path to a CA bundle (per-connection override)
ctx = _build_ssl_context_from_file(value.strip())
if ctx is not None:
return ctx
# Path was invalid — fall back to default
return _GLOBAL_SSL_CONTEXT if _GLOBAL_SSL_CONTEXT is not None else True
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
except (ValueError, TypeError):
AIOHTTP_CLIENT_TIMEOUT = 300
# Optional between-chunks idle cap for streaming aiohttp requests.
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = os.getenv('AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT', '')
if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT == '':
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
else:
try:
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = int(AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT)
except (ValueError, TypeError):
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT is not None and AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT <= 0:
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None
# SSL verification for general outbound requests (OpenAI, OAuth, etc.).
# Accepts "True", "False", or a path to a CA bundle file.
# When "True", falls back to AIOHTTP_CLIENT_SSL_CERT_FILE if set.
AIOHTTP_CLIENT_SESSION_SSL = _parse_ssl_env(os.getenv('AIOHTTP_CLIENT_SESSION_SSL', 'True'))
SEARXNG_CLIENT_CERT_FILE = os.getenv('SEARXNG_CLIENT_CERT_FILE', '').strip()
SEARXNG_CLIENT_KEY_FILE = os.getenv('SEARXNG_CLIENT_KEY_FILE', '').strip()
AIOHTTP_CLIENT_SESSION_SSL = os.getenv('AIOHTTP_CLIENT_SESSION_SSL', 'True').lower() == 'true'
# 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.
@ -653,20 +532,8 @@ try:
except (ValueError, TypeError):
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
AIOHTTP_FILE_STREAM_CHUNK_SIZE = os.getenv('AIOHTTP_FILE_STREAM_CHUNK_SIZE', str(1024 * 1024))
try:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = int(AIOHTTP_FILE_STREAM_CHUNK_SIZE)
except Exception:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
if AIOHTTP_FILE_STREAM_CHUNK_SIZE <= 0:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
# SSL verification for tool server connections specifically.
# Accepts "True", "False", or a path to a CA bundle file.
# When "True", falls back to AIOHTTP_CLIENT_SSL_CERT_FILE if set.
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = _parse_ssl_env(os.getenv('AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL', 'True'))
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = os.getenv('AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL', 'True').lower() == 'true'
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = os.getenv('AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER', '')
@ -749,8 +616,6 @@ WEBUI_SECRET_KEY = os.getenv(
os.getenv('WEBUI_JWT_SECRET_KEY', ''),
)
ENABLE_VALVE_ENCRYPTION = os.getenv('ENABLE_VALVE_ENCRYPTION', 'False').lower() == 'true'
WEBUI_SESSION_COOKIE_SAME_SITE = os.getenv('WEBUI_SESSION_COOKIE_SAME_SITE', 'lax')
WEBUI_SESSION_COOKIE_SECURE = os.getenv('WEBUI_SESSION_COOKIE_SECURE', 'false').lower() == 'true'
WEBUI_AUTH_COOKIE_SAME_SITE = os.getenv('WEBUI_AUTH_COOKIE_SAME_SITE', WEBUI_SESSION_COOKIE_SAME_SITE)
@ -797,7 +662,6 @@ WEBUI_AUTH_TRUSTED_ROLE_HEADER = os.getenv('WEBUI_AUTH_TRUSTED_ROLE_HEADER', Non
CUSTOM_API_KEY_HEADER = os.getenv('CUSTOM_API_KEY_HEADER', 'x-api-key')
ENABLE_PASSWORD_VALIDATION = os.getenv('ENABLE_PASSWORD_VALIDATION', 'False').lower() == 'true'
PASSWORD_HASH_ALGORITHM = os.getenv('PASSWORD_HASH_ALGORITHM', 'bcrypt').lower()
PASSWORD_VALIDATION_REGEX_PATTERN = os.getenv(
'PASSWORD_VALIDATION_REGEX_PATTERN',
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$',
@ -823,19 +687,6 @@ BYPASS_RETRIEVAL_ACCESS_CONTROL = os.getenv('BYPASS_RETRIEVAL_ACCESS_CONTROL', '
# 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
)
# When enabled, skips pydub-based preprocessing (format conversion, compression,
# and chunked splitting) before sending files to processing engines. Useful when
# the upstream provider handles these steps or when ffmpeg is unavailable.
@ -866,18 +717,6 @@ OAUTH_MAX_SESSIONS_PER_USER = int(os.getenv('OAUTH_MAX_SESSIONS_PER_USER', '10')
# Token Exchange Configuration
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = os.getenv('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true'
_oauth_token_exchange_rate_limit = (os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT') or '').strip()
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT = (
int(_oauth_token_exchange_rate_limit)
if _oauth_token_exchange_rate_limit and _oauth_token_exchange_rate_limit.lower() != 'none'
else None
)
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW = int(os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW', str(60 * 3)))
OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS = [
client_id.strip()
for client_id in os.getenv('OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS', '').split(',')
if client_id.strip()
]
# Back-Channel Logout Configuration
# When enabled, exposes POST /oauth/backchannel-logout for IdP-initiated logout
@ -929,18 +768,10 @@ 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', '')
@ -997,10 +828,6 @@ 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)
####################################
@ -1035,7 +862,6 @@ else:
ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION = (
os.getenv('ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION', 'False').lower() == 'true'
)
ENABLE_API_OUTLET_FILTERS = os.getenv('ENABLE_API_OUTLET_FILTERS', 'True').lower() == 'true'
# When enabled, uses a hardcoded extension-to-MIME dictionary as a last-resort
# fallback when both mimetypes.guess_type() and file.meta.content_type fail to
@ -1136,34 +962,10 @@ SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
os.getenv('SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION', 'True').lower() == 'true'
)
####################################
# KNOWLEDGE TOOLS
####################################
def _int_env(name: str, default: int) -> int:
try:
return max(int(os.getenv(name) or default), 1)
except (ValueError, TypeError):
return default
# Total output of a single kb_exec call, whatever the command.
KB_EXEC_MAX_OUTPUT_CHARS = _int_env('KB_EXEC_MAX_OUTPUT_CHARS', 30_000)
# Files a single kb_exec grep may scan before it asks for a narrower scope.
KB_EXEC_MAX_GREP_FILES = _int_env('KB_EXEC_MAX_GREP_FILES', 200)
# Matching lines returned by kb_exec grep and grep_knowledge_files.
KNOWLEDGE_GREP_MAX_MATCHES = _int_env('KNOWLEDGE_GREP_MAX_MATCHES', 50)
# Characters returned by view_file / view_knowledge_file.
VIEW_FILE_MAX_CHARS = _int_env('VIEW_FILE_MAX_CHARS', 100_000)
VIEW_FILE_DEFAULT_MAX_CHARS = _int_env('VIEW_FILE_DEFAULT_MAX_CHARS', 10_000)
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
####################################
ENABLE_PLUGINS = os.getenv('ENABLE_PLUGINS', 'True').lower() == 'true'
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
os.getenv('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS', 'True').lower() == 'true'
)
@ -1183,12 +985,6 @@ if OFFLINE_MODE:
os.environ['HF_HUB_OFFLINE'] = '1'
ENABLE_VERSION_UPDATE_CHECK = False
####################################
# Pyodide file persistence
####################################
ENABLE_PYODIDE_FILE_PERSISTENCE = os.getenv('ENABLE_PYODIDE_FILE_PERSISTENCE', 'false').lower() == 'true'
####################################
# Audit logging
####################################
@ -1217,19 +1013,15 @@ except ValueError:
MAX_BODY_LOG_SIZE = 2048
# Comma separated list for urls to exclude from audit
AUDIT_EXCLUDED_PATHS = [
path
for path in (
path.strip().lstrip('/') for path in os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
)
if path
]
AUDIT_EXCLUDED_PATHS = os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_EXCLUDED_PATHS]
# Comma separated list of urls to include in audit (whitelist mode)
# When set, only these paths are audited and AUDIT_EXCLUDED_PATHS is ignored
AUDIT_INCLUDED_PATHS = [
path for path in (path.strip().lstrip('/') for path in os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')) if path
]
AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')
AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS]
AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path]
# When enabled, GET requests are also audited (disabled by default to avoid log noise)
ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true'

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import asyncio
import inspect
import json
import logging
import sys
from typing import AsyncGenerator, Generator, Iterator
@ -19,7 +20,7 @@ from starlette.responses import Response, StreamingResponse
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel
@ -28,7 +29,6 @@ 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,
@ -69,9 +69,6 @@ async def get_function_module_by_id(request: Request, pipe_id: str):
async def get_function_models(request):
if not ENABLE_PLUGINS:
return []
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
pipe_models = []
@ -100,7 +97,7 @@ async def get_function_models(request):
log.exception(e)
sub_pipes = []
log.debug("get_function_models: function '%s' is a manifold of %s", pipe.id, sub_pipes)
log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}")
for p in sub_pipes:
sub_pipe_id = f'{pipe.id}.{p["id"]}'
@ -126,10 +123,7 @@ async def get_function_models(request):
pipe_flag = {'type': 'pipe'}
log.debug(
"get_function_models: function '%s' is a single pipe { 'id': %s, 'name': %s }",
pipe.id,
pipe.id,
pipe.name,
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
)
pipe_models.append(
@ -150,10 +144,7 @@ async def get_function_models(request):
return pipe_models
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
if models is None:
models = {}
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
async def execute_pipe(pipe, params):
if inspect.iscoroutinefunction(pipe):
return await pipe(**params)
@ -173,7 +164,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: {JSONCodec.dumps(line)}'
line = f'data: {json.dumps(line)}'
try:
line = line.decode('utf-8')
@ -184,7 +175,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: {JSONCodec.dumps(line)}\n\n'
return f'data: {json.dumps(line)}\n\n'
def get_pipe_id(form_data: dict) -> str:
pipe_id = form_data['model']
@ -212,13 +203,6 @@ 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}
model_id = form_data.get('model')
model_info = await Models.get_model_by_id(model_id)
@ -294,8 +278,7 @@ 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)
if not bypass_system_prompt:
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
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)
@ -315,17 +298,17 @@ async def generate_function_chat_completion(request, form_data, user, models: di
yield data
return
if isinstance(res, dict):
yield f'data: {JSONCodec.dumps(res)}\n\n'
yield f'data: {json.dumps(res)}\n\n'
return
except Exception as e:
log.error(f'Error: {e}')
yield f'data: {JSONCodec.dumps({"error": {"detail": str(e)}})}\n\n'
yield f'data: {json.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: {JSONCodec.dumps(message)}\n\n'
yield f'data: {json.dumps(message)}\n\n'
if isinstance(res, Iterator):
for line in res:
@ -337,7 +320,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: {JSONCodec.dumps(finish_message)}\n\n'
yield f'data: {json.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
return StreamingResponse(stream_content(), media_type='text/event-stream')

View file

@ -0,0 +1,265 @@
"""Database-backed configuration with environment variable defaults."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from functools import reduce
from typing import Any, Optional, Union
import redis
from open_webui.internal.db import Base, get_async_db, get_db
from open_webui.utils.redis import get_redis_connection
from sqlalchemy import JSON, Column, DateTime, Integer, func, select
log = logging.getLogger(__name__)
# ── Model ────────────────────────────────────────────────────────────────────
class ConfigTable(Base):
__tablename__ = 'config'
id = Column(Integer, primary_key=True)
data = Column(JSON, nullable=False)
version = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=True, onupdate=func.now())
# ── Blob ─────────────────────────────────────────────────────────────────────
class ConfigState:
"""In-memory mirror of the single-row config JSON blob."""
__slots__ = ('_data',)
def __init__(self) -> None:
self._data: dict[str, Any] = {}
@property
def snapshot(self) -> dict:
return self._data
def read(self, path: str) -> Any:
return reduce(
lambda n, k: n.get(k) if isinstance(n, dict) else None,
path.split('.'),
self._data,
)
def write(self, path: str, value: Any) -> None:
keys = path.split('.')
reduce(lambda d, k: d.setdefault(k, {}), keys[:-1], self._data)[keys[-1]] = value
def replace(self, data: dict) -> None:
self._data = data
def load(self) -> dict:
with get_db() as db:
row = db.query(ConfigTable).order_by(ConfigTable.id.desc()).first()
self._data = row.data if row else {'version': 0, 'ui': {}}
return self._data
def persist(self, data: dict | None = None) -> None:
if data is not None:
self._data = data
with get_db() as db:
row = db.query(ConfigTable).first()
if row is None:
db.add(ConfigTable(data=self._data, version=0))
else:
row.data, row.updated_at = self._data, datetime.now()
db.add(row)
db.commit()
async def persist_async(self, data: dict | None = None) -> None:
if data is not None:
self._data = data
async with get_async_db() as db:
result = await db.execute(select(ConfigTable).limit(1))
row = result.scalars().first()
if row is None:
db.add(ConfigTable(data=self._data, version=0))
else:
row.data, row.updated_at = self._data, datetime.now()
db.add(row)
await db.commit()
def clear(self) -> None:
with get_db() as db:
db.query(ConfigTable).delete()
db.commit()
async def clear_async(self) -> None:
from sqlalchemy import delete as sa_delete
async with get_async_db() as db:
await db.execute(sa_delete(ConfigTable))
await db.commit()
STATE = ConfigState()
# ── ConfigVar ──────────────────────────────────────────────────────────────────
_persist_enabled: bool = True
_oauth_persist_enabled: bool = False
_all_configs: list[ConfigVar] = []
def initialize(*, enable_persistent: bool = True, enable_oauth_persistent: bool = False) -> dict:
global _persist_enabled, _oauth_persist_enabled
_persist_enabled = enable_persistent
_oauth_persist_enabled = enable_oauth_persistent
return STATE.load()
class ConfigVar:
__slots__ = ('env_name', 'config_path', 'env_value', 'config_value', 'value')
def __init__(self, env_name: str, config_path: str, env_value: Any) -> None:
self.env_name = env_name
self.config_path = config_path
self.env_value = env_value
self.config_value = STATE.read(config_path)
if self.config_value is not None and _persist_enabled:
if config_path.startswith('oauth.') and not _oauth_persist_enabled:
log.info("Skipping DB value for '%s' (OAuth persistence disabled)", env_name)
self.value = env_value
else:
log.info("'%s' loaded from database", env_name)
self.value = self.config_value
else:
self.value = env_value
_all_configs.append(self)
def __str__(self) -> str:
return str(self.value)
def __repr__(self) -> str:
return f'<ConfigVar {self.env_name}={self.value!r}>'
@property
def __dict__(self): # type: ignore[override]
raise TypeError(f"ConfigVar('{self.env_name}') cannot be cast to dict; use .value")
def __getattribute__(self, item: str):
if item == '__dict__':
raise TypeError('ConfigVar cannot be cast to dict; use .value')
return super().__getattribute__(item)
def refresh(self) -> None:
current = STATE.read(self.config_path)
if current is not None:
self.value = current
log.info('Refreshed %s%s', self.env_name, self.value)
def commit(self) -> None:
log.info("Persisting '%s'", self.env_name)
STATE.write(self.config_path, self.value)
self.config_value = self.value
STATE.persist()
async def commit_async(self) -> None:
log.info("Persisting '%s'", self.env_name)
STATE.write(self.config_path, self.value)
self.config_value = self.value
await STATE.persist_async()
# ── AppConfig ──────────────────────────────────────────────────────────
class AppConfig:
"""Attribute-style container for ConfigVars with optional Redis sync."""
def __init__(
self,
*,
redis_url: Optional[str] = None,
redis_sentinels: Optional[list] = None,
redis_cluster: bool = False,
redis_key_prefix: str = 'open-webui',
) -> None:
super().__setattr__('_entries', {})
super().__setattr__('_key_prefix', redis_key_prefix)
# If sentinels weren't explicitly provided, read from env.
if redis_sentinels is None:
from open_webui.env import REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
from open_webui.utils.redis import get_sentinels_from_env
redis_sentinels = get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT)
rc: Union[redis.Redis, redis.cluster.RedisCluster, None] = None
if redis_url:
rc = get_redis_connection(redis_url, redis_sentinels or [], redis_cluster, decode_responses=True)
super().__setattr__('_rc', rc)
def __setattr__(self, name: str, value: Any) -> None:
entries: dict = super().__getattribute__('_entries')
if isinstance(value, ConfigVar):
entries[name] = value
return
entries[name].value = value
try:
asyncio.get_running_loop().create_task(self._write_async(name))
except RuntimeError:
entries[name].commit()
rc = super().__getattribute__('_rc')
if rc and _persist_enabled:
prefix = super().__getattribute__('_key_prefix')
try:
rc.set(f'{prefix}:config:{name}', json.dumps(entries[name].value))
except Exception as exc:
log.error("Redis write failed for '%s': %s", name, exc)
async def _write_async(self, name: str) -> None:
try:
await self._entries[name].commit_async()
except Exception as exc:
log.error("Async persist failed for '%s': %s", name, exc)
def __getattr__(self, name: str) -> Any:
entries = super().__getattribute__('_entries')
if name not in entries:
raise AttributeError(f"No config key '{name}'")
rc = super().__getattribute__('_rc')
if rc and _persist_enabled:
prefix = super().__getattribute__('_key_prefix')
try:
raw = rc.get(f'{prefix}:config:{name}')
if raw is not None:
decoded = json.loads(raw)
if entries[name].value != decoded:
entries[name].value = decoded
log.info("Updated '%s' from Redis", name)
except Exception as exc:
log.error("Redis read failed for '%s': %s", name, exc)
return entries[name].value
def _sync_to_redis(self) -> None:
rc = super().__getattribute__('_rc')
if not rc or not _persist_enabled:
return
prefix = super().__getattribute__('_key_prefix')
for name, s in super().__getattribute__('_entries').items():
try:
rc.set(f'{prefix}:config:{name}', json.dumps(s.value))
except Exception as exc:
log.error("Redis sync failed for '%s': %s", name, exc)

View file

@ -1,15 +1,14 @@
from __future__ import annotations
import json
import logging
import os
import sys
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from open_webui.env import (
DATABASE_ENABLE_IAM_TOKEN_AUTH,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_POOL_MAX_OVERFLOW,
@ -27,9 +26,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
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, scoped_session, sessionmaker
@ -124,18 +121,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 ``JSONCodec.dumps`` on write and
deserialized with ``JSONCodec.loads`` on read.
PostgreSQL. Values are serialized with ``json.dumps`` on write and
deserialized with ``json.loads`` on read.
"""
impl = types.UnicodeText
cache_ok = True
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
return JSONCodec.dumps(value) if value is not None else None
return json.dumps(value) if value is not None else None
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
return JSONCodec.loads(value) if value is not None else None
return json.loads(value) if value is not None else None
def copy(self, **kwargs: Any) -> Self:
return JSONField(length=self.impl.length)
@ -149,77 +146,6 @@ _url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
class RDSIAMTokenAuth:
_refresh_after = timedelta(minutes=14)
def __init__(self, database_url: str) -> None:
url = make_url(database_url)
if not url.drivername.startswith(('postgresql', 'postgres')):
raise ValueError('DATABASE_ENABLE_IAM_TOKEN_AUTH is only supported for PostgreSQL databases')
if not url.host or not url.username:
raise ValueError('DATABASE_ENABLE_IAM_TOKEN_AUTH requires a database host and user')
self.host = url.host
self.port = url.port or 5432
self.username = url.username
self._client = None
self._token: str | None = None
self._expires_at = datetime.min.replace(tzinfo=timezone.utc)
@property
def client(self):
if self._client is None:
import boto3
self._client = boto3.client('rds')
return self._client
def get_password(self) -> str:
now = datetime.now(timezone.utc)
if self._token and now < self._expires_at:
return self._token
self._token = self.client.generate_db_auth_token(
DBHostname=self.host,
Port=self.port,
DBUsername=self.username,
)
self._expires_at = now + self._refresh_after
log.info('AWS RDS IAM database token refreshed; next refresh after %s', self._expires_at.isoformat())
return self._token
_rds_iam_token_auth = RDSIAMTokenAuth(SQLALCHEMY_DATABASE_URL) if DATABASE_ENABLE_IAM_TOKEN_AUTH else None
def _set_iam_token_password(dialect, conn_rec, cargs, cparams):
if _rds_iam_token_auth is not None:
cparams['password'] = _rds_iam_token_auth.get_password()
def enable_iam_token_auth(connectable) -> None:
if _rds_iam_token_auth is 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)
def _make_async_url(url: str) -> str:
"""Convert a sync database URL to its async driver equivalent.
@ -246,27 +172,6 @@ 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)
@ -295,7 +200,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,
@ -307,7 +212,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
echo=False,
)
else:
engine = _create_engine(
engine = create_engine(
'sqlite://',
creator=create_sqlcipher_connection,
poolclass=NullPool,
@ -317,7 +222,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."""
@ -349,7 +254,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,
@ -359,11 +264,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)
enable_iam_token_auth(engine)
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
# Sync session — used ONLY for startup config loading (config.py runs at import time)
@ -405,15 +308,14 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
# Generous default — async coroutines + no session sharing = high connection demand.
# 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,
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
)
@event.listens_for(async_engine.sync_engine, 'connect')
@ -422,7 +324,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,
@ -431,19 +333,17 @@ 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,
)
enable_iam_token_auth(async_engine)
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,

File diff suppressed because it is too large Load diff

View file

@ -6,11 +6,9 @@ import logging.config
import logging
import alembic.context
from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
from open_webui.internal.db import enable_iam_token_auth, extract_ssl_params_from_url, reattach_ssl_params_to_url
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
from open_webui.models.auths import Auth
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
from open_webui.models.chat_messages import ChatMessage # noqa: F401
from open_webui.models.chats import Chat # noqa: F401
from sqlalchemy import create_engine, engine_from_config, pool
alembic_config = alembic.context.config
@ -70,7 +68,6 @@ def _get_engine_connectable():
def run_migrations_online() -> None:
"""Execute migrations against a live database connection."""
live_connectable = _get_engine_connectable()
enable_iam_token_auth(live_connectable)
with live_connectable.connect() as live_connection:
alembic.context.configure(
connection=live_connection,

View file

@ -1,28 +0,0 @@
"""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')

View file

@ -49,7 +49,7 @@ def upgrade():
# Step 3: Migrate data from 'old_chat' to 'chat' (only if old_chat exists)
# Re-check columns after potential rename above
current_cols = {c['name'] for c in sa.inspect(conn).get_columns('chat')}
current_cols = {c['name'] for c in inspector.get_columns('chat')}
if 'old_chat' in current_cols:
chat_table = table(
'chat',
@ -76,12 +76,8 @@ def upgrade():
def downgrade():
conn = op.get_bind()
columns = {col['name'] for col in sa.inspect(conn).get_columns('chat')}
# Step 1: Add 'old_chat' column back as Text
if 'old_chat' not in columns:
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
# Step 2: Convert 'chat' JSON data back to text and store in 'old_chat'
chat_table = table(
@ -91,14 +87,14 @@ def downgrade():
sa.Column('old_chat', sa.Text()),
)
if 'chat' in columns:
results = conn.execute(select(chat_table.c.id, chat_table.c.chat))
for row in results:
text_data = json.dumps(row.chat) if row.chat is not None else None
conn.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.chat))
for row in results:
text_data = json.dumps(row.chat) if row.chat is not None else None
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
# Step 3: Remove the new 'chat' JSON column
op.drop_column('chat', 'chat')
# Step 3: Remove the new 'chat' JSON column
op.drop_column('chat', 'chat')
# Step 4: Rename 'old_chat' back to 'chat'
op.alter_column('chat', 'old_chat', new_column_name='chat', existing_type=sa.Text())

View file

@ -1,584 +0,0 @@
"""reshape config to per key rows
Revision ID: 3ff2c63645b8
Revises: 461111b60977
Create Date: 2026-06-17 00:50:51.477073
"""
import json
import time
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '3ff2c63645b8'
down_revision: Union[str, None] = '461111b60977'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Maps every dot-notation blob path to its legacy env/config key name.
# Built from the legacy persistent config declarations in config.py.
BLOB_PATH_TO_KEY = {
'audio.stt.allowed_extensions': 'AUDIO_STT_ALLOWED_EXTENSIONS',
'audio.stt.azure.api_key': 'AUDIO_STT_AZURE_API_KEY',
'audio.stt.azure.base_url': 'AUDIO_STT_AZURE_BASE_URL',
'audio.stt.azure.locales': 'AUDIO_STT_AZURE_LOCALES',
'audio.stt.azure.max_speakers': 'AUDIO_STT_AZURE_MAX_SPEAKERS',
'audio.stt.azure.region': 'AUDIO_STT_AZURE_REGION',
'audio.stt.deepgram.api_key': 'DEEPGRAM_API_KEY',
'audio.stt.engine': 'AUDIO_STT_ENGINE',
'audio.stt.mistral.api_base_url': 'AUDIO_STT_MISTRAL_API_BASE_URL',
'audio.stt.mistral.api_key': 'AUDIO_STT_MISTRAL_API_KEY',
'audio.stt.mistral.use_chat_completions': 'AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS',
'audio.stt.model': 'AUDIO_STT_MODEL',
'audio.stt.openai.api_base_url': 'AUDIO_STT_OPENAI_API_BASE_URL',
'audio.stt.openai.api_key': 'AUDIO_STT_OPENAI_API_KEY',
'audio.stt.supported_content_types': 'AUDIO_STT_SUPPORTED_CONTENT_TYPES',
'audio.stt.whisper_model': 'WHISPER_MODEL',
'audio.tts.api_key': 'AUDIO_TTS_API_KEY',
'audio.tts.azure.speech_base_url': 'AUDIO_TTS_AZURE_SPEECH_BASE_URL',
'audio.tts.azure.speech_output_format': 'AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT',
'audio.tts.azure.speech_region': 'AUDIO_TTS_AZURE_SPEECH_REGION',
'audio.tts.engine': 'AUDIO_TTS_ENGINE',
'audio.tts.mistral.api_base_url': 'AUDIO_TTS_MISTRAL_API_BASE_URL',
'audio.tts.mistral.api_key': 'AUDIO_TTS_MISTRAL_API_KEY',
'audio.tts.model': 'AUDIO_TTS_MODEL',
'audio.tts.openai.api_base_url': 'AUDIO_TTS_OPENAI_API_BASE_URL',
'audio.tts.openai.api_key': 'AUDIO_TTS_OPENAI_API_KEY',
'audio.tts.openai.params': 'AUDIO_TTS_OPENAI_PARAMS',
'audio.tts.split_on': 'AUDIO_TTS_SPLIT_ON',
'audio.tts.voice': 'AUDIO_TTS_VOICE',
'auth.admin.email': 'ADMIN_EMAIL',
'auth.admin.show': 'SHOW_ADMIN_DETAILS',
'auth.api_key.allowed_endpoints': 'API_KEYS_ALLOWED_ENDPOINTS',
'auth.api_key.endpoint_restrictions': 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS',
'auth.enable_api_keys': 'ENABLE_API_KEYS',
'auth.jwt_expiry': 'JWT_EXPIRES_IN',
'automations.enable': 'ENABLE_AUTOMATIONS',
'automations.max_count': 'AUTOMATION_MAX_COUNT',
'automations.min_interval': 'AUTOMATION_MIN_INTERVAL',
'calendar.enable': 'ENABLE_CALENDAR',
'channels.enable': 'ENABLE_CHANNELS',
'code_execution.enable': 'ENABLE_CODE_EXECUTION',
'code_execution.engine': 'CODE_EXECUTION_ENGINE',
'code_execution.jupyter.auth': 'CODE_EXECUTION_JUPYTER_AUTH',
'code_execution.jupyter.auth_password': 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD',
'code_execution.jupyter.auth_token': 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN',
'code_execution.jupyter.timeout': 'CODE_EXECUTION_JUPYTER_TIMEOUT',
'code_execution.jupyter.url': 'CODE_EXECUTION_JUPYTER_URL',
'code_interpreter.enable': 'ENABLE_CODE_INTERPRETER',
'code_interpreter.engine': 'CODE_INTERPRETER_ENGINE',
'code_interpreter.jupyter.auth': 'CODE_INTERPRETER_JUPYTER_AUTH',
'code_interpreter.jupyter.auth_password': 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD',
'code_interpreter.jupyter.auth_token': 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN',
'code_interpreter.jupyter.timeout': 'CODE_INTERPRETER_JUPYTER_TIMEOUT',
'code_interpreter.jupyter.url': 'CODE_INTERPRETER_JUPYTER_URL',
'code_interpreter.prompt_template': 'CODE_INTERPRETER_PROMPT_TEMPLATE',
'direct.enable': 'ENABLE_DIRECT_CONNECTIONS',
'evaluation.arena.enable': 'ENABLE_EVALUATION_ARENA_MODELS',
'evaluation.arena.models': 'EVALUATION_ARENA_MODELS',
'file.image_compression_height': 'FILE_IMAGE_COMPRESSION_HEIGHT',
'file.image_compression_width': 'FILE_IMAGE_COMPRESSION_WIDTH',
'folders.enable': 'ENABLE_FOLDERS',
'folders.max_file_count': 'FOLDER_MAX_FILE_COUNT',
'google_drive.api_key': 'GOOGLE_DRIVE_API_KEY',
'google_drive.client_id': 'GOOGLE_DRIVE_CLIENT_ID',
'google_drive.enable': 'ENABLE_GOOGLE_DRIVE_INTEGRATION',
'image_generation.automatic1111.api_auth': 'AUTOMATIC1111_API_AUTH',
'image_generation.automatic1111.api_params': 'AUTOMATIC1111_PARAMS',
'image_generation.automatic1111.base_url': 'AUTOMATIC1111_BASE_URL',
'image_generation.comfyui.api_key': 'COMFYUI_API_KEY',
'image_generation.comfyui.base_url': 'COMFYUI_BASE_URL',
'image_generation.comfyui.nodes': 'COMFYUI_WORKFLOW_NODES',
'image_generation.comfyui.workflow': 'COMFYUI_WORKFLOW',
'image_generation.enable': 'ENABLE_IMAGE_GENERATION',
'image_generation.engine': 'IMAGE_GENERATION_ENGINE',
'image_generation.gemini.api_base_url': 'IMAGES_GEMINI_API_BASE_URL',
'image_generation.gemini.api_key': 'IMAGES_GEMINI_API_KEY',
'image_generation.gemini.endpoint_method': 'IMAGES_GEMINI_ENDPOINT_METHOD',
'image_generation.model': 'IMAGE_GENERATION_MODEL',
'image_generation.openai.api_base_url': 'IMAGES_OPENAI_API_BASE_URL',
'image_generation.openai.api_key': 'IMAGES_OPENAI_API_KEY',
'image_generation.openai.api_version': 'IMAGES_OPENAI_API_VERSION',
'image_generation.openai.params': 'IMAGES_OPENAI_API_PARAMS',
'image_generation.prompt.enable': 'ENABLE_IMAGE_PROMPT_GENERATION',
'image_generation.size': 'IMAGE_SIZE',
'image_generation.steps': 'IMAGE_STEPS',
'images.edit.comfyui.api_key': 'IMAGES_EDIT_COMFYUI_API_KEY',
'images.edit.comfyui.base_url': 'IMAGES_EDIT_COMFYUI_BASE_URL',
'images.edit.comfyui.nodes': 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES',
'images.edit.comfyui.workflow': 'IMAGES_EDIT_COMFYUI_WORKFLOW',
'images.edit.enable': 'ENABLE_IMAGE_EDIT',
'images.edit.engine': 'IMAGE_EDIT_ENGINE',
'images.edit.gemini.api_base_url': 'IMAGES_EDIT_GEMINI_API_BASE_URL',
'images.edit.gemini.api_key': 'IMAGES_EDIT_GEMINI_API_KEY',
'images.edit.model': 'IMAGE_EDIT_MODEL',
'images.edit.openai.api_base_url': 'IMAGES_EDIT_OPENAI_API_BASE_URL',
'images.edit.openai.api_key': 'IMAGES_EDIT_OPENAI_API_KEY',
'images.edit.openai.api_version': 'IMAGES_EDIT_OPENAI_API_VERSION',
'images.edit.size': 'IMAGE_EDIT_SIZE',
'ldap.enable': 'ENABLE_LDAP',
'ldap.group.enable_creation': 'ENABLE_LDAP_GROUP_CREATION',
'ldap.group.enable_management': 'ENABLE_LDAP_GROUP_MANAGEMENT',
'ldap.server.app_dn': 'LDAP_APP_DN',
'ldap.server.app_password': 'LDAP_APP_PASSWORD',
'ldap.server.attribute_for_groups': 'LDAP_ATTRIBUTE_FOR_GROUPS',
'ldap.server.attribute_for_mail': 'LDAP_ATTRIBUTE_FOR_MAIL',
'ldap.server.attribute_for_username': 'LDAP_ATTRIBUTE_FOR_USERNAME',
'ldap.server.ca_cert_file': 'LDAP_CA_CERT_FILE',
'ldap.server.ciphers': 'LDAP_CIPHERS',
'ldap.server.host': 'LDAP_SERVER_HOST',
'ldap.server.label': 'LDAP_SERVER_LABEL',
'ldap.server.port': 'LDAP_SERVER_PORT',
'ldap.server.search_filter': 'LDAP_SEARCH_FILTER',
'ldap.server.use_tls': 'LDAP_USE_TLS',
'ldap.server.users_dn': 'LDAP_SEARCH_BASE',
'ldap.server.validate_cert': 'LDAP_VALIDATE_CERT',
'memories.enable': 'ENABLE_MEMORIES',
'models.base_models_cache': 'ENABLE_BASE_MODELS_CACHE',
'models.default_metadata': 'DEFAULT_MODEL_METADATA',
'models.default_params': 'DEFAULT_MODEL_PARAMS',
'notes.enable': 'ENABLE_NOTES',
# OAuth — direct paths
'oauth.admin_roles': 'OAUTH_ADMIN_ROLES',
'oauth.allowed_domains': 'OAUTH_ALLOWED_DOMAINS',
'oauth.allowed_roles': 'OAUTH_ALLOWED_ROLES',
'oauth.audience': 'OAUTH_AUDIENCE',
'oauth.auto_redirect': 'OAUTH_AUTO_REDIRECT',
'oauth.blocked_groups': 'OAUTH_BLOCKED_GROUPS',
'oauth.client.timeout': 'OAUTH_CLIENT_TIMEOUT',
'oauth.enable_group_creation': 'ENABLE_OAUTH_GROUP_CREATION',
'oauth.enable_group_mapping': 'ENABLE_OAUTH_GROUP_MANAGEMENT',
'oauth.enable_role_mapping': 'ENABLE_OAUTH_ROLE_MANAGEMENT',
'oauth.enable_signup': 'ENABLE_OAUTH_SIGNUP',
'oauth.group_default_share': 'OAUTH_GROUP_DEFAULT_SHARE',
'oauth.merge_accounts_by_email': 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL',
'oauth.refresh_token_include_scope': 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE',
'oauth.roles_claim': 'OAUTH_ROLES_CLAIM',
'oauth.update_email_on_login': 'OAUTH_UPDATE_EMAIL_ON_LOGIN',
'oauth.update_name_on_login': 'OAUTH_UPDATE_NAME_ON_LOGIN',
'oauth.update_picture_on_login': 'OAUTH_UPDATE_PICTURE_ON_LOGIN',
# OAuth — generic provider paths
'oauth.client_id': 'OAUTH_CLIENT_ID',
'oauth.client_secret': 'OAUTH_CLIENT_SECRET',
'oauth.code_challenge_method': 'OAUTH_CODE_CHALLENGE_METHOD',
'oauth.email_claim': 'OAUTH_EMAIL_CLAIM',
'oauth.end_session_endpoint': 'OPENID_END_SESSION_ENDPOINT',
'oauth.group_claim': 'OAUTH_GROUP_CLAIM',
'oauth.picture_claim': 'OAUTH_PICTURE_CLAIM',
'oauth.provider_name': 'OAUTH_PROVIDER_NAME',
'oauth.provider_url': 'OPENID_PROVIDER_URL',
'oauth.redirect_uri': 'OPENID_REDIRECT_URI',
'oauth.scopes': 'OAUTH_SCOPES',
'oauth.sub_claim': 'OAUTH_SUB_CLAIM',
'oauth.timeout': 'OAUTH_TIMEOUT',
'oauth.token_endpoint_auth_method': 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD',
'oauth.username_claim': 'OAUTH_USERNAME_CLAIM',
# OAuth — OIDC nested paths (flattened)
'oauth.oidc.avatar_claim': 'OAUTH_PICTURE_CLAIM',
'oauth.oidc.client_id': 'OAUTH_CLIENT_ID',
'oauth.oidc.client_secret': 'OAUTH_CLIENT_SECRET',
'oauth.oidc.code_challenge_method': 'OAUTH_CODE_CHALLENGE_METHOD',
'oauth.oidc.email_claim': 'OAUTH_EMAIL_CLAIM',
'oauth.oidc.end_session_endpoint': 'OPENID_END_SESSION_ENDPOINT',
'oauth.oidc.group_claim': 'OAUTH_GROUP_CLAIM', # renamed from OAUTH_GROUPS_CLAIM
'oauth.oidc.oauth_timeout': 'OAUTH_TIMEOUT',
'oauth.oidc.provider_name': 'OAUTH_PROVIDER_NAME',
'oauth.oidc.provider_url': 'OPENID_PROVIDER_URL',
'oauth.oidc.redirect_uri': 'OPENID_REDIRECT_URI',
'oauth.oidc.scopes': 'OAUTH_SCOPES',
'oauth.oidc.sub_claim': 'OAUTH_SUB_CLAIM',
'oauth.oidc.token_endpoint_auth_method': 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD',
'oauth.oidc.username_claim': 'OAUTH_USERNAME_CLAIM',
# OAuth — provider-specific
'oauth.feishu.client_id': 'FEISHU_CLIENT_ID',
'oauth.feishu.client_secret': 'FEISHU_CLIENT_SECRET',
'oauth.feishu.redirect_uri': 'FEISHU_REDIRECT_URI',
'oauth.feishu.scope': 'FEISHU_OAUTH_SCOPE',
'oauth.github.client_id': 'GITHUB_CLIENT_ID',
'oauth.github.client_secret': 'GITHUB_CLIENT_SECRET',
'oauth.github.redirect_uri': 'GITHUB_CLIENT_REDIRECT_URI',
'oauth.github.scope': 'GITHUB_CLIENT_SCOPE',
'oauth.google.client_id': 'GOOGLE_CLIENT_ID',
'oauth.google.client_secret': 'GOOGLE_CLIENT_SECRET',
'oauth.google.redirect_uri': 'GOOGLE_REDIRECT_URI',
'oauth.google.scope': 'GOOGLE_OAUTH_SCOPE',
'oauth.microsoft.client_id': 'MICROSOFT_CLIENT_ID',
'oauth.microsoft.client_secret': 'MICROSOFT_CLIENT_SECRET',
'oauth.microsoft.login_base_url': 'MICROSOFT_CLIENT_LOGIN_BASE_URL',
'oauth.microsoft.picture_url': 'MICROSOFT_CLIENT_PICTURE_URL',
'oauth.microsoft.redirect_uri': 'MICROSOFT_REDIRECT_URI',
'oauth.microsoft.scope': 'MICROSOFT_OAUTH_SCOPE',
'oauth.microsoft.tenant_id': 'MICROSOFT_CLIENT_TENANT_ID',
# Ollama / OpenAI
'ollama.api_configs': 'OLLAMA_API_CONFIGS',
'ollama.base_urls': 'OLLAMA_BASE_URLS',
'ollama.enable': 'ENABLE_OLLAMA_API',
'onedrive.enable': 'ENABLE_ONEDRIVE_INTEGRATION',
'onedrive.sharepoint_tenant_id': 'ONEDRIVE_SHAREPOINT_TENANT_ID',
'onedrive.sharepoint_url': 'ONEDRIVE_SHAREPOINT_URL',
'openai.api_base_urls': 'OPENAI_API_BASE_URLS',
'openai.api_configs': 'OPENAI_API_CONFIGS',
'openai.api_keys': 'OPENAI_API_KEYS',
'openai.enable': 'ENABLE_OPENAI_API',
# RAG
'rag.content_extraction_engine': 'CONTENT_EXTRACTION_ENGINE',
'rag.datalab_marker_use_llm': 'DATALAB_MARKER_USE_LLM',
'rag.mistral_ocr_api_base_url': 'MISTRAL_OCR_API_BASE_URL',
'rag.azure_openai.api_key': 'RAG_AZURE_OPENAI_API_KEY',
'rag.azure_openai.api_version': 'RAG_AZURE_OPENAI_API_VERSION',
'rag.azure_openai.base_url': 'RAG_AZURE_OPENAI_BASE_URL',
'rag.bypass_embedding_and_retrieval': 'BYPASS_EMBEDDING_AND_RETRIEVAL',
'rag.chunk_min_size_target': 'CHUNK_MIN_SIZE_TARGET',
'rag.chunk_overlap': 'CHUNK_OVERLAP',
'rag.chunk_size': 'CHUNK_SIZE',
'rag.datalab_marker_additional_config': 'DATALAB_MARKER_ADDITIONAL_CONFIG',
'rag.datalab_marker_api_base_url': 'DATALAB_MARKER_API_BASE_URL',
'rag.datalab_marker_api_key': 'DATALAB_MARKER_API_KEY',
'rag.datalab_marker_disable_image_extraction': 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION',
'rag.datalab_marker_force_ocr': 'DATALAB_MARKER_FORCE_OCR',
'rag.datalab_marker_format_lines': 'DATALAB_MARKER_FORMAT_LINES',
'rag.datalab_marker_output_format': 'DATALAB_MARKER_OUTPUT_FORMAT',
'rag.datalab_marker_paginate': 'DATALAB_MARKER_PAGINATE',
'rag.datalab_marker_skip_cache': 'DATALAB_MARKER_SKIP_CACHE',
'rag.datalab_marker_strip_existing_ocr': 'DATALAB_MARKER_STRIP_EXISTING_OCR',
'rag.docling_api_key': 'DOCLING_API_KEY',
'rag.docling_params': 'DOCLING_PARAMS',
'rag.docling_server_url': 'DOCLING_SERVER_URL',
'rag.document_intelligence_endpoint': 'DOCUMENT_INTELLIGENCE_ENDPOINT',
'rag.document_intelligence_key': 'DOCUMENT_INTELLIGENCE_KEY',
'rag.document_intelligence_model': 'DOCUMENT_INTELLIGENCE_MODEL',
'rag.embedding_batch_size': 'RAG_EMBEDDING_BATCH_SIZE',
'rag.embedding_concurrent_requests': 'RAG_EMBEDDING_CONCURRENT_REQUESTS',
'rag.embedding_engine': 'RAG_EMBEDDING_ENGINE',
'rag.embedding_model': 'RAG_EMBEDDING_MODEL',
'rag.enable_async_embedding': 'ENABLE_ASYNC_EMBEDDING',
'rag.enable_hybrid_search': 'ENABLE_RAG_HYBRID_SEARCH',
'rag.enable_hybrid_search_enriched_texts': 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS',
'rag.enable_markdown_header_text_splitter': 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER',
'rag.external_document_loader_api_key': 'EXTERNAL_DOCUMENT_LOADER_API_KEY',
'rag.external_document_loader_url': 'EXTERNAL_DOCUMENT_LOADER_URL',
'rag.external_reranker_api_key': 'RAG_EXTERNAL_RERANKER_API_KEY',
'rag.external_reranker_timeout': 'RAG_EXTERNAL_RERANKER_TIMEOUT',
'rag.external_reranker_url': 'RAG_EXTERNAL_RERANKER_URL',
'rag.file.allowed_extensions': 'RAG_ALLOWED_FILE_EXTENSIONS',
'rag.file.max_count': 'RAG_FILE_MAX_COUNT',
'rag.file.max_size': 'RAG_FILE_MAX_SIZE',
'rag.full_context': 'RAG_FULL_CONTEXT',
'rag.hybrid_bm25_weight': 'RAG_HYBRID_BM25_WEIGHT',
'rag.mineru_api_key': 'MINERU_API_KEY',
'rag.mineru_api_mode': 'MINERU_API_MODE',
'rag.mineru_api_timeout': 'MINERU_API_TIMEOUT',
'rag.mineru_api_url': 'MINERU_API_URL',
'rag.mineru_file_extensions': 'MINERU_FILE_EXTENSIONS',
'rag.mineru_params': 'MINERU_PARAMS',
'rag.mistral_ocr_api_key': 'MISTRAL_OCR_API_KEY',
'rag.ollama.key': 'RAG_OLLAMA_API_KEY',
'rag.ollama.url': 'RAG_OLLAMA_BASE_URL',
'rag.openai_api_base_url': 'RAG_OPENAI_API_BASE_URL',
'rag.openai_api_key': 'RAG_OPENAI_API_KEY',
'rag.paddleocr_vl_base_url': 'PADDLEOCR_VL_BASE_URL',
'rag.paddleocr_vl_token': 'PADDLEOCR_VL_TOKEN',
'rag.pdf_extract_images': 'PDF_EXTRACT_IMAGES',
'rag.pdf_loader_mode': 'PDF_LOADER_MODE',
'rag.relevance_threshold': 'RAG_RELEVANCE_THRESHOLD',
'rag.reranking_batch_size': 'RAG_RERANKING_BATCH_SIZE',
'rag.reranking_engine': 'RAG_RERANKING_ENGINE',
'rag.reranking_model': 'RAG_RERANKING_MODEL',
'rag.template': 'RAG_TEMPLATE',
'rag.text_splitter': 'RAG_TEXT_SPLITTER',
'rag.tika_server_url': 'TIKA_SERVER_URL',
'rag.tiktoken_encoding_name': 'TIKTOKEN_ENCODING_NAME',
'rag.top_k': 'RAG_TOP_K',
'rag.top_k_reranker': 'RAG_TOP_K_RERANKER',
# RAG — Web
'rag.web.fetch.max_content_length': 'WEB_FETCH_MAX_CONTENT_LENGTH',
'rag.web.loader.concurrent_requests': 'WEB_LOADER_CONCURRENT_REQUESTS',
'rag.web.loader.engine': 'WEB_LOADER_ENGINE',
'rag.web.loader.external_web_loader_api_key': 'EXTERNAL_WEB_LOADER_API_KEY',
'rag.web.loader.external_web_loader_url': 'EXTERNAL_WEB_LOADER_URL',
'rag.web.loader.firecrawl_api_key': 'FIRECRAWL_API_KEY',
'rag.web.loader.firecrawl_api_url': 'FIRECRAWL_API_BASE_URL',
'rag.web.loader.firecrawl_timeout': 'FIRECRAWL_TIMEOUT',
'rag.web.loader.playwright_timeout': 'PLAYWRIGHT_TIMEOUT',
'rag.web.loader.playwright_ws_url': 'PLAYWRIGHT_WS_URL',
'rag.web.loader.ssl_verification': 'ENABLE_WEB_LOADER_SSL_VERIFICATION',
'rag.web.loader.timeout': 'WEB_LOADER_TIMEOUT',
'rag.web.search.azure_ai_search_api_key': 'AZURE_AI_SEARCH_API_KEY',
'rag.web.search.azure_ai_search_endpoint': 'AZURE_AI_SEARCH_ENDPOINT',
'rag.web.search.azure_ai_search_index_name': 'AZURE_AI_SEARCH_INDEX_NAME',
'rag.web.search.bing_search_v7_endpoint': 'BING_SEARCH_V7_ENDPOINT',
'rag.web.search.bing_search_v7_subscription_key': 'BING_SEARCH_V7_SUBSCRIPTION_KEY',
'rag.web.search.bocha_search_api_key': 'BOCHA_SEARCH_API_KEY',
'rag.web.search.brave_search_api_key': 'BRAVE_SEARCH_API_KEY',
'rag.web.search.brave_search_context_tokens': 'BRAVE_SEARCH_CONTEXT_TOKENS',
'rag.web.search.bypass_embedding_and_retrieval': 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL',
'rag.web.search.bypass_web_loader': 'BYPASS_WEB_SEARCH_WEB_LOADER',
'rag.web.search.concurrent_requests': 'WEB_SEARCH_CONCURRENT_REQUESTS',
'rag.web.search.ddgs_backend': 'DDGS_BACKEND',
'rag.web.search.domain.filter_list': 'WEB_SEARCH_DOMAIN_FILTER_LIST',
'rag.web.search.enable': 'ENABLE_WEB_SEARCH',
'rag.web.search.engine': 'WEB_SEARCH_ENGINE',
'rag.web.search.exa_api_key': 'EXA_API_KEY',
'rag.web.search.external_web_search_api_key': 'EXTERNAL_WEB_SEARCH_API_KEY',
'rag.web.search.external_web_search_url': 'EXTERNAL_WEB_SEARCH_URL',
'rag.web.search.google_pse_api_key': 'GOOGLE_PSE_API_KEY',
'rag.web.search.google_pse_engine_id': 'GOOGLE_PSE_ENGINE_ID',
'rag.web.search.jina_api_base_url': 'JINA_API_BASE_URL',
'rag.web.search.jina_api_key': 'JINA_API_KEY',
'rag.web.search.kagi_search_api_key': 'KAGI_SEARCH_API_KEY',
'rag.web.search.linkup_api_key': 'LINKUP_API_KEY',
'rag.web.search.linkup_search_params': 'LINKUP_SEARCH_PARAMS',
'rag.web.search.mojeek_search_api_key': 'MOJEEK_SEARCH_API_KEY',
'rag.web.search.ollama_cloud_api_key': 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY',
'rag.web.search.perplexity_api_key': 'PERPLEXITY_API_KEY',
'rag.web.search.perplexity_model': 'PERPLEXITY_MODEL',
'rag.web.search.perplexity_search_api_url': 'PERPLEXITY_SEARCH_API_URL',
'rag.web.search.perplexity_search_context_usage': 'PERPLEXITY_SEARCH_CONTEXT_USAGE',
'rag.web.search.result_count': 'WEB_SEARCH_RESULT_COUNT',
'rag.web.search.searchapi_api_key': 'SEARCHAPI_API_KEY',
'rag.web.search.searchapi_engine': 'SEARCHAPI_ENGINE',
'rag.web.search.searxng_language': 'SEARXNG_LANGUAGE',
'rag.web.search.searxng_query_url': 'SEARXNG_QUERY_URL',
'rag.web.search.serpapi_api_key': 'SERPAPI_API_KEY',
'rag.web.search.serpapi_engine': 'SERPAPI_ENGINE',
'rag.web.search.serper_api_key': 'SERPER_API_KEY',
'rag.web.search.serply_api_key': 'SERPLY_API_KEY',
'rag.web.search.serpstack_api_key': 'SERPSTACK_API_KEY',
'rag.web.search.serpstack_https': 'SERPSTACK_HTTPS',
'rag.web.search.sougou_api_sid': 'SOUGOU_API_SID',
'rag.web.search.sougou_api_sk': 'SOUGOU_API_SK',
'rag.web.search.tavily_api_key': 'TAVILY_API_KEY',
'rag.web.search.tavily_extract_depth': 'TAVILY_EXTRACT_DEPTH',
'rag.web.search.trust_env': 'WEB_SEARCH_TRUST_ENV',
'rag.web.search.yacy_password': 'YACY_PASSWORD',
'rag.web.search.yacy_query_url': 'YACY_QUERY_URL',
'rag.web.search.yacy_username': 'YACY_USERNAME',
'rag.web.search.yandex_web_search_api_key': 'YANDEX_WEB_SEARCH_API_KEY',
'rag.web.search.yandex_web_search_config': 'YANDEX_WEB_SEARCH_CONFIG',
'rag.web.search.yandex_web_search_url': 'YANDEX_WEB_SEARCH_URL',
'rag.web.search.youcom_api_key': 'YOUCOM_API_KEY',
'rag.youtube_loader_language': 'YOUTUBE_LOADER_LANGUAGE',
'rag.youtube_loader_proxy_url': 'YOUTUBE_LOADER_PROXY_URL',
# Tasks
'task.autocomplete.enable': 'ENABLE_AUTOCOMPLETE_GENERATION',
'task.autocomplete.input_max_length': 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH',
'task.autocomplete.prompt_template': 'AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE',
'task.follow_up.enable': 'ENABLE_FOLLOW_UP_GENERATION',
'task.follow_up.prompt_template': 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE',
'task.image.prompt_template': 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE',
'task.model.default': 'TASK_MODEL',
'task.model.external': 'TASK_MODEL_EXTERNAL',
'task.query.prompt_template': 'QUERY_GENERATION_PROMPT_TEMPLATE',
'task.query.retrieval.enable': 'ENABLE_RETRIEVAL_QUERY_GENERATION',
'task.query.search.enable': 'ENABLE_SEARCH_QUERY_GENERATION',
'task.tags.enable': 'ENABLE_TAGS_GENERATION',
'task.tags.prompt_template': 'TAGS_GENERATION_PROMPT_TEMPLATE',
'task.title.enable': 'ENABLE_TITLE_GENERATION',
'task.title.prompt_template': 'TITLE_GENERATION_PROMPT_TEMPLATE',
'task.tools.prompt_template': 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE',
'task.voice.prompt.enable': 'ENABLE_VOICE_MODE_PROMPT',
'task.voice.prompt_template': 'VOICE_MODE_PROMPT_TEMPLATE',
# Misc
'terminal_server.connections': 'TERMINAL_SERVER_CONNECTIONS',
'tool_server.connections': 'TOOL_SERVER_CONNECTIONS',
'ui.banners': 'WEBUI_BANNERS',
'ui.default_group_id': 'DEFAULT_GROUP_ID',
'ui.default_locale': 'DEFAULT_LOCALE',
'ui.default_models': 'DEFAULT_MODELS',
'ui.default_pinned_models': 'DEFAULT_PINNED_MODELS',
'ui.default_user_role': 'DEFAULT_USER_ROLE',
'ui.enable_community_sharing': 'ENABLE_COMMUNITY_SHARING',
'ui.enable_login_form': 'ENABLE_LOGIN_FORM',
'ui.enable_message_rating': 'ENABLE_MESSAGE_RATING',
'ui.enable_password_change_form': 'ENABLE_PASSWORD_CHANGE_FORM',
'ui.enable_signup': 'ENABLE_SIGNUP',
'ui.enable_user_webhooks': 'ENABLE_USER_WEBHOOKS',
'ui.model_order_list': 'MODEL_ORDER_LIST',
'ui.pending_user_overlay_content': 'PENDING_USER_OVERLAY_CONTENT',
'ui.pending_user_overlay_title': 'PENDING_USER_OVERLAY_TITLE',
'ui.prompt_suggestions': 'DEFAULT_PROMPT_SUGGESTIONS',
'ui.watermark': 'RESPONSE_WATERMARK',
'user.permissions': 'USER_PERMISSIONS',
'users.enable_status': 'ENABLE_USER_STATUS',
'webhook_url': 'WEBHOOK_URL',
'webui.url': 'WEBUI_URL',
}
STORAGE_KEY_REWRITES = {
'oauth.refresh_token_include_scope': 'oauth.refresh_token.include_scope',
'rag.openai_api_base_url': 'rag.openai.api_base_url',
'rag.openai_api_key': 'rag.openai.api_key',
'rag.ollama.url': 'rag.ollama.base_url',
'rag.ollama.key': 'rag.ollama.api_key',
'oauth.oidc.avatar_claim': 'oauth.picture_claim',
'oauth.oidc.client_id': 'oauth.client_id',
'oauth.oidc.client_secret': 'oauth.client_secret',
'oauth.oidc.code_challenge_method': 'oauth.code_challenge_method',
'oauth.oidc.email_claim': 'oauth.email_claim',
'oauth.oidc.end_session_endpoint': 'oauth.end_session_endpoint',
'oauth.oidc.group_claim': 'oauth.group_claim',
'oauth.oidc.oauth_timeout': 'oauth.timeout',
'oauth.oidc.provider_name': 'oauth.provider_name',
'oauth.oidc.provider_url': 'oauth.provider_url',
'oauth.oidc.redirect_uri': 'oauth.redirect_uri',
'oauth.oidc.scopes': 'oauth.scopes',
'oauth.oidc.sub_claim': 'oauth.sub_claim',
'oauth.oidc.token_endpoint_auth_method': 'oauth.token_endpoint_auth_method',
'oauth.oidc.username_claim': 'oauth.username_claim',
}
LEGACY_KEY_TO_STORAGE_KEY = {
legacy_key: STORAGE_KEY_REWRITES.get(blob_path, blob_path) for blob_path, legacy_key in BLOB_PATH_TO_KEY.items()
}
def _walk_blob(data: dict, prefix: str = '') -> dict:
"""Recursively walk a nested config blob, preserving known config values.
Some config values are intentionally dictionaries, e.g. OPENAI_API_CONFIGS
and OLLAMA_API_CONFIGS. Once the current path is a known config key, keep
that value intact instead of flattening its internals into orphaned rows.
"""
result = {}
for key, value in data.items():
path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}'
if path in BLOB_PATH_TO_KEY or path in LEGACY_KEY_TO_STORAGE_KEY:
result[path] = value
elif isinstance(value, dict):
result.update(_walk_blob(value, path))
else:
result[path] = value
return result
def upgrade() -> None:
"""Reshape config from single-row JSON blob to per-key rows."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
config_columns = (
{column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
)
has_old_config = {'id', 'data'}.issubset(config_columns)
has_new_config = {'key', 'value'}.issubset(config_columns)
# Ad-hoc table reference for reading the old schema
old_config = sa.table(
'config',
sa.column('id', sa.Integer),
sa.column('data', sa.JSON),
)
# 1. Read existing blob
blob_data = {}
if has_old_config:
try:
result = conn.execute(sa.select(old_config.c.data).order_by(old_config.c.id.desc()).limit(1))
row = result.fetchone()
if row and row[0]:
raw = row[0]
blob_data = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
pass # Table might be partially migrated or empty
# 2. Preserve old blob table for rollback/inspection, then create per-key table.
if has_old_config:
if 'config_old' in table_names:
op.drop_table('config_old')
op.rename_table('config', 'config_old')
# 3. Create new per-key table
new_config = (
sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON()),
sa.column('updated_at', sa.BigInteger),
)
if has_new_config
else op.create_table(
'config',
sa.Column('key', sa.Text(), primary_key=True),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
)
# 4. Flatten blob and insert per-key rows
if blob_data:
flat = _walk_blob(blob_data)
# Keep stable dot-notation paths as the database keys.
# Known legacy env-style keys are rewritten to their dotted keys; unknown
# keys are still copied so custom/future config is not silently lost.
rows = {}
for blob_path, value in flat.items():
if blob_path in BLOB_PATH_TO_KEY:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
elif blob_path in LEGACY_KEY_TO_STORAGE_KEY:
storage_key = LEGACY_KEY_TO_STORAGE_KEY[blob_path]
else:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
if storage_key not in rows:
rows[storage_key] = value
# Batch insert via SQLAlchemy table reference
if rows:
now = int(time.time())
op.bulk_insert(
new_config,
[{'key': k, 'value': v, 'updated_at': now} for k, v in rows.items()],
)
def downgrade() -> None:
"""Restore preserved old single-row config table when available."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
if 'config_old' in table_names:
if 'config' in table_names:
op.drop_table('config')
op.rename_table('config_old', 'config')
return
config_columns = (
{column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
)
has_per_key_config = {'key', 'value'}.issubset(config_columns)
blob_data = {}
if has_per_key_config:
config = sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON),
)
for key, value in conn.execute(sa.select(config.c.key, config.c.value)):
blob_data[key] = json.loads(value) if isinstance(value, str) else value
op.drop_table('config')
if 'config' in table_names and not has_per_key_config:
return
old_config = op.create_table(
'config',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=True),
)
if blob_data:
op.bulk_insert(old_config, [{'data': blob_data, 'version': 0}])

View file

@ -1,40 +0,0 @@
"""add memory path and meta
Revision ID: 42e2978c7933
Revises: 7b3f2a9c1d4e
Create Date: 2026-06-29 05:35:50.565887
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '42e2978c7933'
down_revision: Union[str, None] = '7b3f2a9c1d4e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
if 'path' not in columns:
op.add_column('memory', sa.Column('path', sa.Text(), nullable=True))
if 'meta' not in columns:
op.add_column('memory', sa.Column('meta', sa.JSON(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
if 'meta' in columns:
op.drop_column('memory', 'meta')
if 'path' in columns:
op.drop_column('memory', 'path')

View file

@ -1,37 +0,0 @@
"""add context summary to chat message
Revision ID: 4c5ce3d2f27f
Revises: 3ff2c63645b8
Create Date: 2026-06-18 23:48:08.310063
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4c5ce3d2f27f'
down_revision: Union[str, None] = '3ff2c63645b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('chat_message')}
if 'context_summary' not in columns:
op.add_column('chat_message', sa.Column('context_summary', sa.Text(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('chat_message')}
if 'context_summary' in columns:
op.drop_column('chat_message', 'context_summary')

View file

@ -1,36 +0,0 @@
"""Add memory (id, user_id) covering index
Revision ID: 55f1302ac17c
Revises: b0018471bbbe
Create Date: 2026-07-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '55f1302ac17c'
down_revision: Union[str, None] = 'b0018471bbbe'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_id_user_id' not in indexes:
op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id'])
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_id_user_id' in indexes:
op.drop_index('ix_memory_id_user_id', table_name='memory')

View file

@ -1,64 +0,0 @@
"""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

View file

@ -1,44 +0,0 @@
"""add memory type
Revision ID: 7b3f2a9c1d4e
Revises: 4c5ce3d2f27f
Create Date: 2026-06-25 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '7b3f2a9c1d4e'
down_revision: Union[str, None] = '4c5ce3d2f27f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'type' not in columns:
op.add_column('memory', sa.Column('type', sa.String(), server_default='context', nullable=False))
if 'ix_memory_type' not in indexes:
op.create_index('ix_memory_type', 'memory', ['type'])
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_type' in indexes:
op.drop_index('ix_memory_type', table_name='memory')
if 'type' in columns:
op.drop_column('memory', 'type')

View file

@ -1,25 +0,0 @@
"""add chat message meta
Revision ID: 856c5b02fb54
Revises: 42e2978c7933
Create Date: 2026-07-16 01:39:39.291935
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '856c5b02fb54'
down_revision: Union[str, None] = '42e2978c7933'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat_message', sa.Column('meta', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('chat_message', 'meta')

View file

@ -1,54 +0,0 @@
"""add automation folder id
Revision ID: 959eaac8f909
Revises: 55f1302ac17c
Create Date: 2026-07-26 19:19:31.345756
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = '959eaac8f909'
down_revision: str | None = '55f1302ac17c'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
if context.is_offline_mode():
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
return
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {col['name'] for col in inspector.get_columns('automation')}
indexes = {index['name'] for index in inspector.get_indexes('automation')}
if 'folder_id' not in columns:
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
if 'ix_automation_user_folder' not in indexes:
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
def downgrade() -> None:
if context.is_offline_mode():
op.drop_index('ix_automation_user_folder', table_name='automation')
op.drop_column('automation', 'folder_id')
return
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {col['name'] for col in inspector.get_columns('automation')}
indexes = {index['name'] for index in inspector.get_indexes('automation')}
if 'ix_automation_user_folder' in indexes:
op.drop_index('ix_automation_user_folder', table_name='automation')
if 'folder_id' in columns:
op.drop_column('automation', 'folder_id')

View file

@ -1,219 +0,0 @@
"""add current_message_id to chat
Revision ID: 9a1b2c3d4e5f
Revises: 856c5b02fb54
Create Date: 2026-07-23 00:00:00.000000
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '9a1b2c3d4e5f'
down_revision: Union[str, None] = '856c5b02fb54'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
BATCH_SIZE = 150
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'current_message_id' not in columns:
op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True))
chat = sa.table(
'chat',
sa.column('id', sa.String()),
sa.column('chat', sa.Text()),
sa.column('current_message_id', sa.Text()),
)
chat_message = sa.table(
'chat_message',
sa.column('id', sa.Text()),
sa.column('chat_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
has_chat_message = 'chat_message' in inspector.get_table_names()
result = conn.execute(
sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options(
yield_per=BATCH_SIZE,
stream_results=True,
)
)
while True:
rows = result.fetchmany(BATCH_SIZE)
if not rows:
break
batch_chat_ids: list[str] = []
candidates_by_chat: dict[str, list[str]] = {}
current_by_chat: dict[str, str | None] = {}
json_messages_by_chat: dict[str, dict[str, dict]] = {}
for row in rows:
values = row._mapping
chat_id = values['id']
prefix = f'{chat_id}-'
batch_chat_ids.append(chat_id)
current_by_chat[chat_id] = values['current_message_id']
chat_data = {}
if isinstance(values['chat'], dict):
chat_data = values['chat']
elif isinstance(values['chat'], str):
try:
parsed = json.loads(values['chat'])
chat_data = parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError, json.JSONDecodeError):
pass
history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {}
candidates_by_chat[chat_id] = []
for candidate in (
values['current_message_id'],
history.get('currentId'),
chat_data.get('currentId'),
chat_data.get('branchPointMessageId'),
):
if not isinstance(candidate, str) or not candidate:
continue
candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate
if candidate not in candidates_by_chat[chat_id]:
candidates_by_chat[chat_id].append(candidate)
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(chat_data.get('messages'), list):
messages = {
message['id']: message
for message in chat_data['messages']
if isinstance(message, dict) and message.get('id')
}
if messages:
json_messages_by_chat[chat_id] = {
message_id: {
'parent_id': message.get('parentId') if isinstance(message, dict) else None,
'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0,
}
for message_id, message in messages.items()
}
resolved: dict[str, str] = {}
if has_chat_message:
candidate_ids = {
f'{chat_id}-{candidate}'
for chat_id, candidates in candidates_by_chat.items()
for candidate in candidates
}
if candidate_ids:
valid_by_chat: dict[str, set[str]] = {}
for row in conn.execute(
sa.select(chat_message.c.chat_id, chat_message.c.id).where(
chat_message.c.chat_id.in_(batch_chat_ids),
chat_message.c.id.in_(candidate_ids),
)
):
values = row._mapping
chat_id = values['chat_id']
prefix = f'{chat_id}-'
message_id = values['id']
if message_id and message_id.startswith(prefix):
message_id = message_id[len(prefix) :]
if message_id:
valid_by_chat.setdefault(chat_id, set()).add(message_id)
for chat_id, candidates in candidates_by_chat.items():
valid_ids = valid_by_chat.get(chat_id, set())
for candidate in candidates:
if candidate in valid_ids:
resolved[chat_id] = candidate
break
unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved]
messages_by_chat: dict[str, dict[str, dict]] = {}
if unresolved_chat_ids:
for row in conn.execute(
sa.select(
chat_message.c.chat_id,
chat_message.c.id,
chat_message.c.parent_id,
chat_message.c.created_at,
).where(chat_message.c.chat_id.in_(unresolved_chat_ids))
):
values = row._mapping
chat_id = values['chat_id']
prefix = f'{chat_id}-'
message_id = values['id']
if message_id and message_id.startswith(prefix):
message_id = message_id[len(prefix) :]
if not message_id:
continue
parent_id = values['parent_id']
if parent_id and parent_id.startswith(prefix):
parent_id = parent_id[len(prefix) :]
messages_by_chat.setdefault(chat_id, {})[message_id] = {
'parent_id': parent_id,
'created_at': values['created_at'] or 0,
}
for chat_id, messages in messages_by_chat.items():
parent_ids = {
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
}
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
resolved[chat_id] = max(
leaf_ids or list(messages),
key=lambda message_id: messages[message_id].get('created_at') or 0,
)
for chat_id in batch_chat_ids:
if chat_id in resolved:
continue
messages = json_messages_by_chat.get(chat_id, {})
valid_candidate = next(
(candidate for candidate in candidates_by_chat[chat_id] if candidate in messages),
None,
)
if valid_candidate:
resolved[chat_id] = valid_candidate
elif messages:
parent_ids = {
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
}
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
resolved[chat_id] = max(
leaf_ids or list(messages),
key=lambda message_id: messages[message_id].get('created_at') or 0,
)
updates = [
{'chat_id': chat_id, 'current_message_id': message_id}
for chat_id, message_id in resolved.items()
if message_id and message_id != current_by_chat.get(chat_id)
]
if updates:
conn.execute(
sa.update(chat)
.where(chat.c.id == sa.bindparam('update_chat_id'))
.values(current_message_id=sa.bindparam('update_current_message_id')),
[
{
'update_chat_id': row['chat_id'],
'update_current_message_id': row['current_message_id'],
}
for row in updates
],
)
def downgrade() -> None:
op.drop_column('chat', 'current_message_id')

View file

@ -1,32 +0,0 @@
"""add user variables
Revision ID: b0018471bbbe
Revises: c49178636c78
Create Date: 2026-07-24 01:21:46.457057
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b0018471bbbe'
down_revision: Union[str, None] = 'c49178636c78'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('user')]
if 'variables' not in columns:
op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'variables')

View file

@ -72,6 +72,7 @@ def _convert_column_to_json(table: str, column: str):
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.Text))
t_json = sa.column(f'{column}_json', sa.JSON)
# SQLite cannot ALTER COLUMN → must recreate column
if dialect == 'sqlite':
@ -89,9 +90,9 @@ def _convert_column_to_json(table: str, column: str):
parsed = None
conn.execute(
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_json', sa.JSON)))
sa.update(sa.table(table, sa.column('id'), t_json))
.where(sa.column('id') == uid)
.values({f'{column}_json': parsed})
.values({f'{column}_json': json.dumps(parsed) if parsed else None})
)
op.drop_column(table, column)
@ -111,7 +112,8 @@ def _convert_column_to_text(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.JSON))
t = sa.table(table, sa.column('id', sa.Text), sa.column(column))
t_text = sa.column(f'{column}_text', sa.Text)
if dialect == 'sqlite':
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
@ -120,9 +122,9 @@ def _convert_column_to_text(table: str, column: str):
for uid, raw in rows:
conn.execute(
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_text', sa.Text)))
sa.update(sa.table(table, sa.column('id'), t_text))
.where(sa.column('id') == uid)
.values({f'{column}_text': json.dumps(raw) if raw is not None else None})
.values({f'{column}_text': json.dumps(raw) if raw else None})
)
op.drop_column(table, column)
@ -185,7 +187,9 @@ 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={provider: {'sub': sub}}))
conn.execute(
sa.update(_user).where(_user.c.id == uid).values(oauth=json.dumps({provider: {'sub': sub}}))
)
# ── Migrate api_key column → api_key table (only if old column still exists)
if 'api_key' in user_columns:
@ -224,7 +228,7 @@ def downgrade() -> None:
for uid, oauth in rows:
try:
data = oauth if isinstance(oauth, dict) else json.loads(oauth)
data = json.loads(oauth)
provider = list(data.keys())[0]
sub = data[provider].get('sub')
oauth_sub = f'{provider}@{sub}'

View file

@ -1,32 +0,0 @@
"""add chat variables
Revision ID: c49178636c78
Revises: 9a1b2c3d4e5f
Create Date: 2026-07-23 23:33:45.497453
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c49178636c78'
down_revision: Union[str, None] = '9a1b2c3d4e5f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'variables' not in columns:
op.add_column('chat', sa.Column('variables', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('chat', 'variables')

View file

@ -1,70 +0,0 @@
"""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')

View file

@ -1,84 +0,0 @@
"""add unique normalized user email index
Revision ID: f0bd01a18a3d
Revises: 959eaac8f909
Create Date: 2026-07-27 04:41:12.708743
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = 'f0bd01a18a3d'
down_revision: str | None = '959eaac8f909'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
INDEX_NAME = 'uq_user_email_lower'
EMAIL_IS_NOT_NULL = sa.text('email IS NOT NULL')
LOWER_EMAIL = sa.text('lower(email)')
def _index_exists() -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return INDEX_NAME in {index['name'] for index in inspector.get_indexes('user')}
def _duplicate_emails() -> list:
conn = op.get_bind()
return conn.execute(
sa.text(
"""
SELECT lower(email) AS email, count(*) AS duplicate_count
FROM "user"
WHERE email IS NOT NULL
GROUP BY lower(email)
HAVING count(*) > 1
ORDER BY lower(email)
"""
)
).fetchall()
def _create_index() -> None:
op.create_index(
INDEX_NAME,
'user',
[LOWER_EMAIL],
unique=True,
postgresql_where=EMAIL_IS_NOT_NULL,
sqlite_where=EMAIL_IS_NOT_NULL,
)
def upgrade() -> None:
if context.is_offline_mode():
_create_index()
return
if _index_exists():
return
duplicates = _duplicate_emails()
if duplicates:
details = ', '.join(f'{row.email} (x{row.duplicate_count})' for row in duplicates)
raise RuntimeError(
'Cannot add unique normalized user email index because duplicate emails exist: '
f'{details}. Merge or remove the duplicate users and rerun migrations.'
)
_create_index()
def downgrade() -> None:
if context.is_offline_mode():
op.drop_index(INDEX_NAME, table_name='user')
return
if _index_exists():
op.drop_index(INDEX_NAME, table_name='user')

View file

@ -11,11 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
PRINCIPAL_TYPE_ANYONE = 'anyone'
PRINCIPAL_TYPE_GROUP = 'group'
PRINCIPAL_TYPE_USER = 'user'
WILDCARD_PRINCIPAL_ID = '*'
####################
# AccessGrant DB Schema
@ -28,7 +23,7 @@ class AccessGrant(Base):
id = Column(Text, primary_key=True)
resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
resource_id = Column(Text, nullable=False)
principal_type = Column(Text, nullable=False) # "user", "group", or "anyone"
principal_type = Column(Text, nullable=False) # "user" or "group"
principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public)
permission = Column(Text, nullable=False) # "read" or "write"
created_at = Column(BigInteger, nullable=False)
@ -168,14 +163,12 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
principal_id = grant.get('principal_id')
permission = grant.get('permission')
if principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE):
if principal_type not in ('user', 'group'):
continue
if permission not in ('read', 'write'):
continue
if not isinstance(principal_id, str) or not principal_id:
continue
if principal_type == PRINCIPAL_TYPE_ANYONE and (principal_id != WILDCARD_PRINCIPAL_ID or permission != 'read'):
continue
key = (principal_type, principal_id, permission)
deduped[key] = {
@ -193,11 +186,7 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes wildcard public-read.
"""
for grant in normalize_access_grants(access_grants):
if (
grant['principal_type'] == PRINCIPAL_TYPE_USER
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'read'
):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read':
return True
return False
@ -207,25 +196,7 @@ def has_public_write_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes wildcard public-write.
"""
for grant in normalize_access_grants(access_grants):
if (
grant['principal_type'] == PRINCIPAL_TYPE_USER
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'write'
):
return True
return False
def has_anyone_read_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes no-auth anyone-read.
"""
for grant in normalize_access_grants(access_grants):
if (
grant['principal_type'] == PRINCIPAL_TYPE_ANYONE
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
and grant['permission'] == 'read'
):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write':
return True
return False
@ -235,7 +206,7 @@ def has_user_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes any non-wildcard user grant.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == PRINCIPAL_TYPE_USER and grant['principal_id'] != WILDCARD_PRINCIPAL_ID:
if grant['principal_type'] == 'user' and grant['principal_id'] != '*':
return True
return False
@ -252,27 +223,12 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list:
for grant in access_grants
if not (
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
== PRINCIPAL_TYPE_USER
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None))
!= WILDCARD_PRINCIPAL_ID
== 'user'
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) != '*'
)
]
def strip_anyone_access_grants(access_grants: Optional[list]) -> list:
"""
Remove no-auth anyone grants from the list.
"""
if not access_grants:
return []
return [
grant
for grant in access_grants
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
!= PRINCIPAL_TYPE_ANYONE
]
def grants_to_access_control(grants: list) -> Optional[dict]:
"""
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
@ -360,6 +316,7 @@ class AccessGrantsTable:
)
db.add(grant)
await db.commit()
await db.refresh(grant)
return AccessGrantModel.model_validate(grant)
async def revoke_access(
@ -537,28 +494,6 @@ class AccessGrantsTable:
result_dict[g.resource_id].append(AccessGrantModel.model_validate(g))
return result_dict
async def has_anyone_access(
self,
resource_type: str,
resource_id: str,
permission: str = 'read',
db: Optional[AsyncSession] = None,
) -> bool:
"""Check for a no-auth anyone:* grant. Callers must opt in explicitly."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == resource_id,
AccessGrant.principal_type == PRINCIPAL_TYPE_ANYONE,
AccessGrant.principal_id == WILDCARD_PRINCIPAL_ID,
AccessGrant.permission == permission,
)
.limit(1)
)
return result.scalars().first() is not None
async def has_access(
self,
user_id: str,
@ -839,8 +774,7 @@ class AccessGrantsTable:
):
"""
Filter for items where user has read BUT NOT write access.
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.
Public items are NOT considered read_only.
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
@ -851,6 +785,7 @@ class AccessGrantsTable:
from sqlalchemy import exists as sa_exists
# Has read grant (not public)
read_grant_exists = (
select(AccessGrant.id)
.where(
@ -858,10 +793,6 @@ class AccessGrantsTable:
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == 'read',
or_(
and_(
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
*(
[
and_(
@ -888,6 +819,7 @@ class AccessGrantsTable:
.exists()
)
# Does NOT have write grant
write_grant_exists = (
select(AccessGrant.id)
.where(
@ -895,10 +827,6 @@ class AccessGrantsTable:
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == 'write',
or_(
and_(
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
*(
[
and_(
@ -925,7 +853,21 @@ class AccessGrantsTable:
.exists()
)
conditions = [read_grant_exists, ~write_grant_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]
# Not owner
if user_id:

View file

@ -6,22 +6,15 @@ import logging
import uuid
from typing import Optional
import bcrypt
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, field_validator
from sqlalchemy import Boolean, Column, String, Text, delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
# Pre-computed hash verified on signin paths that lack a real credential
# (unknown user, inactive account) so response timing cannot reveal
# whether an account exists (CWE-208).
PLACEHOLDER_HASH = bcrypt.hashpw(b'placeholder', bcrypt.gensalt()).decode('utf-8')
class Auth(Base): # credential ↔ user linkage
"""Maps a user ID to an email/password pair with an active flag."""
@ -125,20 +118,18 @@ class AuthsTable:
)
session.add(credential)
try:
created_user = await Users.insert_new_user(
new_id,
name,
email,
profile_image_url,
role,
oauth=oauth,
db=session,
)
await session.commit()
except IntegrityError:
await session.rollback()
raise
created_user = await Users.insert_new_user(
new_id,
name,
email,
profile_image_url,
role,
oauth=oauth,
db=session,
)
# persist both records and reload generated defaults
await session.commit()
await session.refresh(credential)
return created_user if credential and created_user else None
async def authenticate_user(
@ -151,15 +142,13 @@ class AuthsTable:
log.info('authenticate_user: %s', email)
resolved = await Users.get_user_by_email(email, db=db)
if not resolved:
await verify_password(PLACEHOLDER_HASH)
return
# load the credential row and verify the password hash
async with get_async_db_context(db) as session:
credential = await session.get(Auth, resolved.id)
if not credential or not credential.active:
await verify_password(PLACEHOLDER_HASH)
return
if not await verify_password(credential.password):
if not verify_password(credential.password):
return
return resolved

View file

@ -1,10 +1,9 @@
import logging
import time
from typing import Literal, Optional
from typing import 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
@ -22,7 +21,6 @@ class Automation(Base):
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
folder_id = Column(Text, nullable=True)
name = Column(Text, nullable=False)
data = Column(JSON, nullable=False) # {prompt, model_id, rrule}
meta = Column(JSON, nullable=True)
@ -33,10 +31,7 @@ class Automation(Base):
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
Index('ix_automation_next_run', 'next_run_at'),
Index('ix_automation_user_folder', 'user_id', 'folder_id'),
)
__table_args__ = (Index('ix_automation_next_run', 'next_run_at'),)
class AutomationRun(Base):
@ -65,17 +60,11 @@ 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):
@ -83,7 +72,6 @@ class AutomationModel(BaseModel):
id: str
user_id: str
folder_id: Optional[str] = None
name: str
data: dict
meta: Optional[dict] = None
@ -108,7 +96,6 @@ class AutomationRunModel(BaseModel):
class AutomationForm(BaseModel):
name: str
folder_id: Optional[str] = None
data: AutomationData
meta: Optional[dict] = None
is_active: Optional[bool] = True
@ -142,7 +129,6 @@ class AutomationTable:
row = Automation(
id=str(uuid4()),
user_id=user_id,
folder_id=form.folder_id,
name=form.name,
data=form.data.model_dump(),
meta=form.meta,
@ -153,6 +139,7 @@ class AutomationTable:
)
db.add(row)
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int:
@ -178,7 +165,6 @@ class AutomationTable:
user_id: str,
query: Optional[str] = None,
status: Optional[str] = None,
folder_id: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[AsyncSession] = None,
@ -186,16 +172,13 @@ class AutomationTable:
async with get_async_db_context(db) as db:
stmt = select(Automation).filter_by(user_id=user_id)
if folder_id:
stmt = stmt.filter(Automation.folder_id == folder_id)
if query:
# Search the name column and the prompt inside the JSON data.
data_text = cast(Automation.data, String)
search = f'%{query}%'
# Search in name and prompt inside JSON data
stmt = stmt.filter(
or_(
Automation.name.ilike(f'%{query}%'),
*(data_text.ilike(f'%{variant}%') for variant in json_text_variants(query)),
Automation.name.ilike(search),
cast(Automation.data, String).ilike(search),
)
)
@ -234,7 +217,6 @@ class AutomationTable:
if not row:
return None
row.name = form.name
row.folder_id = form.folder_id
row.data = form.data.model_dump()
row.meta = form.meta
if form.is_active is not None:
@ -242,25 +224,9 @@ class AutomationTable:
row.next_run_at = next_run_at
row.updated_at = int(time.time_ns())
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def clear_folder_ids(
self,
user_id: str,
folder_ids: list[str],
db: Optional[AsyncSession] = None,
) -> int:
if not folder_ids:
return 0
async with get_async_db_context(db) as db:
result = await db.execute(
update(Automation)
.where(Automation.user_id == user_id, Automation.folder_id.in_(folder_ids))
.values(folder_id=None, updated_at=int(time.time_ns()))
)
await db.commit()
return result.rowcount or 0
async def toggle(
self,
id: str,
@ -275,6 +241,7 @@ class AutomationTable:
row.next_run_at = next_run_at if row.is_active else None
row.updated_at = int(time.time_ns())
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool:
@ -357,6 +324,7 @@ class AutomationRunTable:
)
db.add(row)
await db.commit()
await db.refresh(row)
return AutomationRunModel.model_validate(row)
async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]:

View file

@ -241,11 +241,11 @@ class CalendarTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> CalendarModel:
calendar_model = CalendarModel.model_validate(cal)
calendar_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(calendar_model.id, db=db)
cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'})
cal_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(cal_data['id'], db=db)
)
return calendar_model
return CalendarModel.model_validate(cal_data)
async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
"""Return user's calendars, creating 'Personal' default if none exist."""
@ -500,12 +500,9 @@ class CalendarEventTable:
# Filter to requested calendars only
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
# Also get event IDs where the user is an attendee, excluding invites they declined
# Also get event IDs where user is an attendee
attendee_event_ids_result = await db.execute(
select(CalendarEventAttendee.event_id).filter(
CalendarEventAttendee.user_id == user_id,
CalendarEventAttendee.status != 'declined',
)
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
)
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
@ -734,10 +731,10 @@ class CalendarEventTable:
events = []
for event, tz in rows:
model = CalendarEventModel.model_validate(event)
# 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
# Determine per-event alert window
alert_minutes = None
if model.meta and 'alert_minutes' in model.meta:
alert_minutes = model.meta['alert_minutes']
if alert_minutes is not None:
if alert_minutes < 0:
@ -767,32 +764,22 @@ class CalendarEventAttendeeTable:
async def set_attendees(
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
"""Replace all attendees for an event ({user_id, meta?} per dict).
"""Replace all attendees for an event.
RSVP status is the attendee's alone to set (via update_rsvp): an existing
attendee keeps their status, a newly added one starts 'pending'. A
caller-supplied status is ignored so an organiser cannot set it for others.
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
"""
async with get_async_db_context(db) as db:
existing_status = {
row.user_id: row.status
for row in (
await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
).scalars()
}
# Remove existing
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
now = int(time.time_ns())
models = []
for att in attendees:
user_id = att['user_id']
row = CalendarEventAttendee(
id=str(uuid4()),
event_id=event_id,
user_id=user_id,
status=existing_status.get(user_id, 'pending'),
user_id=att['user_id'],
status=att.get('status', 'pending'),
meta=att.get('meta'),
created_at=now,
updated_at=now,

View file

@ -1,3 +1,4 @@
import json
import secrets
import time
import uuid
@ -9,7 +10,6 @@ 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 (
@ -266,11 +266,11 @@ class ChannelTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> ChannelModel:
channel_model = ChannelModel.model_validate(channel)
channel_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(channel_model.id, db=db)
channel_data = ChannelModel.model_validate(channel).model_dump(exclude={'access_grants'})
channel_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(channel_data['id'], db=db)
)
return channel_model
return ChannelModel.model_validate(channel_data)
async def _collect_unique_user_ids(
self,
@ -438,17 +438,17 @@ class ChannelTable:
match_count = func.sum(
case(
(User.id.in_(unique_user_ids), 1),
(ChannelMember.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)
# Match the exact set of accounts that still exist.
.having(func.count(User.id) == len(unique_user_ids))
# 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
.having(match_count == len(unique_user_ids))
.subquery()
)
@ -869,6 +869,7 @@ class ChannelTable:
result = ChannelFile(**channel_file.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return ChannelFileModel.model_validate(result)
else:

View file

@ -1,14 +1,10 @@
import json
import time
import uuid
from collections import Counter
from datetime import datetime, timedelta
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import select, delete, func, cast, Integer, distinct
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.response import merge_usage, normalize_usage
from open_webui.utils.response import normalize_usage
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
JSON,
@ -49,17 +45,6 @@ def _normalize_timestamp(timestamp: int) -> float:
return timestamp
def _timezone(tz: Optional[str]) -> ZoneInfo:
try:
return ZoneInfo(tz or 'UTC')
except ZoneInfoNotFoundError:
return ZoneInfo('UTC')
def _date_key(timestamp: int, tz: ZoneInfo) -> str:
return datetime.fromtimestamp(_normalize_timestamp(timestamp), tz=tz).strftime('%Y-%m-%d')
def get_usage(data: dict) -> Optional[dict]:
"""Extract and normalize usage from message data."""
usage = data.get('usage') or (data.get('info') or {}).get('usage')
@ -85,40 +70,6 @@ def _token_columns(dialect: str):
)
def _extract_tool_names(value: Any) -> list[str]:
names: list[str] = []
def add(name: Any):
if isinstance(name, str):
cleaned = name.strip()
if cleaned and len(cleaned) <= 128:
names.append(cleaned)
def walk(item: Any):
if isinstance(item, list):
for child in item:
walk(child)
return
if not isinstance(item, dict):
return
item_type = str(item.get('type') or '')
looks_like_tool = 'tool' in item_type or item_type in {'function_call', 'function_call_output'}
if looks_like_tool:
add(item.get('name') or item.get('tool_name'))
function = item.get('function')
if isinstance(function, dict):
add(function.get('name'))
for key in ('tool_calls', 'tools', 'output', 'meta'):
if key in item:
walk(item.get(key))
walk(value)
return names
####################
# ChatMessage DB Schema
####################
@ -147,7 +98,6 @@ class ChatMessage(Base):
files = Column(JSON, nullable=True)
sources = Column(JSON, nullable=True)
embeds = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
# Status
done = Column(Boolean, default=True)
@ -157,9 +107,6 @@ class ChatMessage(Base):
# Usage (tokens, timing, etc.)
usage = Column(JSON, nullable=True)
# Context compaction checkpoint
context_summary = Column(Text, nullable=True)
# Timestamps
created_at = Column(BigInteger, index=True)
updated_at = Column(BigInteger)
@ -168,7 +115,6 @@ 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
)
@ -191,12 +137,10 @@ class ChatMessageModel(BaseModel):
files: Optional[list] = None
sources: Optional[list] = None
embeds: Optional[list] = None
meta: Optional[dict] = None
done: bool = True
status_history: Optional[list] = None
error: Optional[dict | str] = None
usage: Optional[dict] = None
context_summary: Optional[str] = None
created_at: int
updated_at: int
@ -207,66 +151,6 @@ 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,
@ -278,67 +162,80 @@ 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}'
message = await db.get(ChatMessage, composite_id)
if message:
self._apply_message_data(message, data, now)
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 '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')
# Extract and normalize usage
usage = get_usage(data)
if usage:
# Deep-merge: preserve existing keys not present in new data
# This prevents background tasks (follow-ups, title, tags)
# from accidentally clearing the primary response's token counts
existing.usage = {**(existing.usage or {}), **usage}
existing.updated_at = now
await db.commit()
await db.refresh(existing)
return ChatMessageModel.model_validate(existing)
else:
message = self._build_message(composite_id, chat_id, user_id, data, now)
# 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'),
done=data.get('done', True),
status_history=data.get('status_history') or data.get('statusHistory'),
error=data.get('error'),
usage=usage,
created_at=timestamp,
updated_at=now,
)
db.add(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()
await db.commit()
await db.refresh(message)
return ChatMessageModel.model_validate(message)
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
async with get_async_db_context(db) as db:
message = await db.get(ChatMessage, id)
return ChatMessageModel.model_validate(message) if message else None
async def has_unfinished_assistant_by_chat_id(
self,
chat_id: str,
db: Optional[AsyncSession] = None,
) -> bool:
async with get_async_db_context(db) as db:
result = await db.execute(
select(ChatMessage.id)
.where(ChatMessage.chat_id == chat_id)
.where(ChatMessage.role == 'assistant')
.where(ChatMessage.done.is_(False))
.limit(1)
)
return result.scalar_one_or_none() is not None
async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
@ -352,7 +249,6 @@ class ChatMessageTable:
'parent_id': 'parentId',
'model_id': 'model',
'status_history': 'statusHistory',
'context_summary': 'contextSummary',
'created_at': 'timestamp',
}
# DB-internal columns excluded from the reconstructed message dict.
@ -544,44 +440,6 @@ class ChatMessageTable:
result = await db.execute(stmt)
return {row.model_id: row.count for row in result.all()}
async def get_unique_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, dict]:
"""Count distinct users and chats per model."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
stmt = select(
ChatMessage.model_id,
func.count(distinct(ChatMessage.user_id)).label('unique_users'),
func.count(distinct(ChatMessage.chat_id)).label('unique_chats'),
).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.model_id)
result = await db.execute(stmt)
return {
row.model_id: {
'unique_users': row.unique_users,
'unique_chats': row.unique_chats,
}
for row in result.all()
}
async def get_token_usage_by_model(
self,
start_date: Optional[int] = None,
@ -680,233 +538,6 @@ class ChatMessageTable:
for row in result.all()
}
async def get_user_usage_summary(
self,
user_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
include_active_days: bool = True,
timezone: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict:
async with get_async_db_context(db) as db:
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
messages_stmt = select(ChatMessage.role, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.user_id == user_id,
)
token_stmt = select(
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
).filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.usage.isnot(None),
)
models_stmt = select(func.count(distinct(ChatMessage.model_id)).label('models_used')).filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
messages_stmt = messages_stmt.filter(ChatMessage.created_at >= start_date)
token_stmt = token_stmt.filter(ChatMessage.created_at >= start_date)
models_stmt = models_stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
messages_stmt = messages_stmt.filter(ChatMessage.created_at <= end_date)
token_stmt = token_stmt.filter(ChatMessage.created_at <= end_date)
models_stmt = models_stmt.filter(ChatMessage.created_at <= end_date)
messages_result = await db.execute(messages_stmt.group_by(ChatMessage.role))
message_counts = {row.role: row.count for row in messages_result.all()}
token_result = (await db.execute(token_stmt)).one()
models_used = (await db.execute(models_stmt)).scalar() or 0
active_days = set()
if include_active_days:
tz = _timezone(timezone)
day_stmt = select(ChatMessage.created_at).filter(ChatMessage.user_id == user_id)
if start_date:
day_stmt = day_stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
day_stmt = day_stmt.filter(ChatMessage.created_at <= end_date)
day_result = await db.execute(day_stmt)
active_days = {_date_key(row.created_at, tz) for row in day_result.all()}
input_total = int(token_result.input_tokens or 0)
output_total = int(token_result.output_tokens or 0)
return {
'messages': sum(message_counts.values()),
'user_messages': message_counts.get('user', 0),
'assistant_messages': message_counts.get('assistant', 0),
'input_tokens': input_total,
'output_tokens': output_total,
'total_tokens': input_total + output_total,
'models_used': int(models_used),
'active_days': len(active_days),
}
async def get_user_first_message_created_at(
self,
user_id: str,
db: Optional[AsyncSession] = None,
) -> Optional[int]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(func.min(ChatMessage.created_at)).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at.isnot(None),
)
)
value = result.scalar()
return int(value) if value else None
async def get_user_daily_usage(
self,
user_id: str,
start_date: int,
end_date: int,
timezone: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
tz = _timezone(timezone)
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
stmt = select(
ChatMessage.created_at,
ChatMessage.chat_id,
ChatMessage.role,
ChatMessage.model_id,
ChatMessage.usage,
input_tokens.label('input_tokens'),
output_tokens.label('output_tokens'),
).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
result = await db.execute(stmt)
daily: dict[str, dict] = {}
for row in result.all():
date = _date_key(row.created_at, tz)
entry = daily.setdefault(
date,
{
'date': date,
'messages': 0,
'chat_ids': set(),
'tokens': 0,
'models': Counter(),
},
)
entry['messages'] += 1
entry['chat_ids'].add(row.chat_id)
if row.role == 'assistant' and row.model_id:
entry['models'][row.model_id] += 1
if row.usage:
entry['tokens'] += int(row.input_tokens or 0) + int(row.output_tokens or 0)
current = datetime.fromtimestamp(_normalize_timestamp(start_date), tz=tz).replace(
hour=0, minute=0, second=0, microsecond=0
)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date), tz=tz).replace(
hour=0, minute=0, second=0, microsecond=0
)
while current <= end_dt:
date = current.strftime('%Y-%m-%d')
daily.setdefault(
date,
{'date': date, 'messages': 0, 'chat_ids': set(), 'tokens': 0, 'models': Counter()},
)
current += timedelta(days=1)
return [
{
'date': item['date'],
'messages': item['messages'],
'chats': len(item['chat_ids']),
'tokens': item['tokens'],
'models': dict(item['models']),
}
for item in sorted(daily.values(), key=lambda x: x['date'])
]
async def get_user_top_models(
self,
user_id: str,
start_date: int,
end_date: int,
limit: int = 5,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
bind = await db.connection()
dialect = bind.dialect.name
input_tokens, output_tokens = _token_columns(dialect)
stmt = (
select(
ChatMessage.model_id,
func.count(ChatMessage.id).label('messages'),
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
)
.filter(
ChatMessage.user_id == user_id,
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
.group_by(ChatMessage.model_id)
.order_by(func.count(ChatMessage.id).desc())
.limit(limit)
)
result = await db.execute(stmt)
return [
{
'model_id': row.model_id,
'messages': row.messages,
'input_tokens': int(row.input_tokens or 0),
'output_tokens': int(row.output_tokens or 0),
'total_tokens': int(row.input_tokens or 0) + int(row.output_tokens or 0),
}
for row in result.all()
]
async def get_user_top_tools(
self,
user_id: str,
start_date: int,
end_date: int,
limit: int = 5,
db: Optional[AsyncSession] = None,
) -> list[dict]:
async with get_async_db_context(db) as db:
stmt = select(ChatMessage.output, ChatMessage.meta).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at >= start_date,
ChatMessage.created_at <= end_date,
)
result = await db.execute(stmt)
counts: Counter[str] = Counter()
for output, meta in result.all():
for name in _extract_tool_names(output):
counts[name] += 1
for name in _extract_tool_names(meta):
counts[name] += 1
return [{'name': name, 'count': count} for name, count in counts.most_common(limit)]
async def get_message_count_by_user(
self,
start_date: Optional[int] = None,

File diff suppressed because it is too large Load diff

View file

@ -1,382 +0,0 @@
"""Database-backed configuration with per-key storage.
Replaces the old single-row JSON blob machinery with a simple per-key model
mirroring cptr's Config.
Each config key is stored as its own row: key TEXT PK, value JSON.
Reads are direct DB lookups. Writes are explicit awaited upserts that raise on
failure (no more fire-and-forget create_task).
"""
from __future__ import annotations
import logging
import time
from typing import Any, ClassVar
from fastapi.encoders import jsonable_encoder
from open_webui.internal.db import Base, get_async_db
from sqlalchemy import JSON, BigInteger, Column, Text, delete, select
log = logging.getLogger(__name__)
API_CONFIG_KEYS = ('openai.api_configs', 'ollama.api_configs')
DICT_CONFIG_KEY_ALIASES = {
'openai.api_configs': ('OPENAI_API_CONFIGS',),
'ollama.api_configs': ('OLLAMA_API_CONFIGS',),
'rag.mineru_params': ('MINERU_PARAMS',),
'rag.docling_params': ('DOCLING_PARAMS',),
'web.search.linkup_search_params': ('LINKUP_SEARCH_PARAMS',),
'image_generation.automatic1111.api_params': ('AUTOMATIC1111_PARAMS',),
'image_generation.openai.params': ('IMAGES_OPENAI_API_PARAMS',),
'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)
API_CONFIG_FIELDS = (
'enable',
'key',
'prefix_id',
'tags',
'model_ids',
'connection_type',
'provider',
'auth_type',
'headers',
'azure',
'api_type',
'api_version',
'extra_params',
'passthrough_params',
)
def _split_api_config_fragment(fragment: str) -> tuple[str, list[str]] | None:
if not fragment:
return None
first, _, rest = fragment.partition('.')
if first.isdigit() and rest:
return first, rest.split('.')
match: tuple[int, str] | None = None
for field in API_CONFIG_FIELDS:
marker = f'.{field}'
marker_index = fragment.rfind(marker)
if marker_index != -1 and (match is None or marker_index > match[0]):
match = (marker_index, field)
if match:
marker_index, field = match
connection_key = fragment[:marker_index]
field_path = fragment[marker_index + 1 :]
if connection_key:
return connection_key, field_path.split('.')
return None
def _assign_path(target: dict, path: list[str], value: Any) -> None:
current = target
for part in path[:-1]:
next_value = current.get(part)
if not isinstance(next_value, dict):
next_value = {}
current[part] = next_value
current = next_value
current[path[-1]] = value
def _json_value(value: Any) -> Any:
return jsonable_encoder(value)
# ── Model ────────────────────────────────────────────────────────────────────
class Config(Base):
"""Per-key config storage. Each row is one config key."""
__tablename__ = 'config'
key = Column(Text, primary_key=True)
value = Column(JSON, nullable=False)
updated_at = Column(BigInteger, nullable=True)
DEFAULTS: ClassVar[dict[str, Any]] = {}
PERSISTENT_ENABLED: ClassVar[bool] = True
OAUTH_PERSISTENT_ENABLED: ClassVar[bool] = False
# ── Class methods ────────────────────────────────────────
@classmethod
def configure(
cls,
*,
defaults: dict[str, Any] | None = None,
enable_persistent: bool = True,
enable_oauth_persistent: bool = False,
) -> None:
cls.DEFAULTS = dict(defaults or {})
cls.PERSISTENT_ENABLED = enable_persistent
cls.OAUTH_PERSISTENT_ENABLED = enable_oauth_persistent
@classmethod
def default_value(cls, key: str, default: Any = None) -> Any:
return cls.DEFAULTS.get(key, default)
@classmethod
def persistent_enabled_for(cls, key: str) -> bool:
if not cls.PERSISTENT_ENABLED:
return False
if key.startswith('oauth.') and not cls.OAUTH_PERSISTENT_ENABLED:
return False
return True
@staticmethod
async def get(key: str, default: Any = None) -> Any:
"""Get a config value by key. Returns default if not set."""
if not Config.persistent_enabled_for(key):
return Config.default_value(key, default)
async with get_async_db() as db:
row = await db.get(Config, key)
return row.value if row else Config.default_value(key, default)
@staticmethod
async def get_many(*keys: str) -> dict:
"""Get multiple config values. Returns {key: value} for keys that exist."""
disabled_values = {
key: Config.default_value(key)
for key in keys
if not Config.persistent_enabled_for(key) and key in Config.DEFAULTS
}
enabled_keys = {key for key in keys if Config.persistent_enabled_for(key)}
if not enabled_keys:
return disabled_values
async with get_async_db() as db:
result = await db.execute(select(Config).where(Config.key.in_(enabled_keys)))
values = {row.key: row.value for row in result.scalars().all()}
return {
key: values.get(key, Config.default_value(key))
for key in keys
if key in values or key in Config.DEFAULTS or key in disabled_values
}
@staticmethod
async def get_namespace(namespace: str) -> dict:
"""Get all config keys under a dotted namespace."""
default_values = {
key: value
for key, value in Config.DEFAULTS.items()
if key.startswith(f'{namespace}.') and not Config.persistent_enabled_for(key)
}
if not Config.PERSISTENT_ENABLED:
return default_values
async with get_async_db() as db:
result = await db.execute(select(Config).where(Config.key.like(f'{namespace}.%')))
values = {row.key: row.value for row in result.scalars().all()}
values.update(default_values)
return values
@staticmethod
async def get_all() -> dict:
"""Get all config as {key: value}."""
if not Config.PERSISTENT_ENABLED:
return dict(Config.DEFAULTS)
async with get_async_db() as db:
result = await db.execute(select(Config))
values = {row.key: row.value for row in result.scalars().all()}
if not Config.OAUTH_PERSISTENT_ENABLED:
values.update({key: value for key, value in Config.DEFAULTS.items() if key.startswith('oauth.')})
return values
@staticmethod
async def upsert(updates: dict) -> None:
"""Upsert multiple config key-value pairs. Raises on failure."""
persistent_updates = {}
for key, value in updates.items():
value = _json_value(value)
if Config.persistent_enabled_for(key):
persistent_updates[key] = value
else:
Config.DEFAULTS[key] = value
if not persistent_updates:
return
async with get_async_db() as db:
now = int(time.time())
for key, value in persistent_updates.items():
existing = await db.get(Config, key)
if existing:
existing.value = value
existing.updated_at = now
else:
db.add(Config(key=key, value=value, updated_at=now))
await db.commit()
@staticmethod
async def delete(key: str) -> bool:
"""Delete a config key. Returns True if it existed."""
async with get_async_db() as db:
row = await db.get(Config, key)
if row:
await db.delete(row)
await db.commit()
return True
return False
@staticmethod
async def clear() -> None:
"""Delete all config rows."""
async with get_async_db() as db:
await db.execute(delete(Config))
await db.commit()
@staticmethod
async def seed_defaults(defaults: dict) -> None:
"""Insert keys that don't yet exist in the DB.
Called at startup to ensure all known config keys have values.
Existing DB values take precedence over defaults.
"""
async with get_async_db() as db:
result = await db.execute(select(Config.key))
existing_keys = {row[0] for row in result.all()}
now = int(time.time())
new_count = 0
for key, value in defaults.items():
# Skip keys the DB is not authoritative for (e.g. oauth.* while
# ENABLE_OAUTH_PERSISTENT_CONFIG is off), matching the read paths.
if not Config.persistent_enabled_for(key):
continue
if key not in existing_keys:
value = _json_value(value)
db.add(Config(key=key, value=value, updated_at=now))
existing_keys.add(key)
new_count += 1
if new_count:
await db.commit()
log.info('Seeded %d new config defaults', new_count)
@staticmethod
async def rename_prefix(old_prefix: str, new_prefix: str) -> None:
"""Move persisted config keys from one dotted prefix to another."""
if not Config.PERSISTENT_ENABLED:
return
async with get_async_db() as db:
result = await db.execute(select(Config).where(Config.key.like(f'{old_prefix}.%')))
rows = result.scalars().all()
if not rows:
return
now = int(time.time())
moved_count = 0
deleted_count = 0
for row in rows:
new_key = f'{new_prefix}.{row.key.removeprefix(f"{old_prefix}.")}'
existing = await db.get(Config, new_key)
if existing is None:
db.add(Config(key=new_key, value=row.value, updated_at=now))
moved_count += 1
else:
deleted_count += 1
await db.delete(row)
await db.commit()
log.info(
'Renamed %d config keys from %s.* to %s.*; deleted %d old duplicates',
moved_count,
old_prefix,
new_prefix,
deleted_count,
)
@staticmethod
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)
rows = []
for key_prefix in prefixes:
result = await db.execute(select(Config).where(Config.key.like(f'{key_prefix}.%')))
rows.extend(result.scalars().all())
if not rows:
continue
existing = await db.get(Config, config_key)
repaired = existing.value if existing and isinstance(existing.value, dict) else {}
repaired_any = False
for row in rows:
fragment = None
for key_prefix in prefixes:
prefix = f'{key_prefix}.'
if row.key.startswith(prefix):
fragment = row.key.removeprefix(prefix)
break
if fragment is None:
continue
if config_key in API_CONFIG_KEYS:
split = _split_api_config_fragment(fragment)
if not split:
continue
object_key, field_path = split
else:
object_key, field_path = None, fragment.split('.')
target = repaired
if object_key is not None:
target = repaired.setdefault(object_key, {})
if not isinstance(target, dict):
continue
_assign_path(target, field_path, row.value)
orphan_keys.append(row.key)
repaired_any = True
if not repaired_any:
continue
if existing:
existing.value = repaired
existing.updated_at = now
else:
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)))
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()
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))

View file

@ -133,12 +133,6 @@ class ModelHistoryEntry(BaseModel):
lost: int
class ModelHistoryCounts(BaseModel):
date: str
won: int = 0
lost: int = 0
class ModelHistoryResponse(BaseModel):
model_id: str
history: list[ModelHistoryEntry]
@ -165,6 +159,7 @@ class FeedbackTable:
result = Feedback(**feedback.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FeedbackModel.model_validate(result)
else:
@ -221,15 +216,12 @@ class FeedbackTable:
) -> FeedbackListResponse:
async with get_async_db_context(db) as db:
stmt = select(Feedback, User).join(User, Feedback.user_id == User.id)
count_stmt = select(func.count(Feedback.id)).select_from(Feedback).join(User, Feedback.user_id == User.id)
if filter:
# Apply model_id filter (exact match)
model_id = filter.get('model_id')
if model_id:
model_id_filter = Feedback.data['model_id'].as_string() == model_id
stmt = stmt.filter(model_id_filter)
count_stmt = count_stmt.filter(model_id_filter)
stmt = stmt.filter(Feedback.data['model_id'].as_string() == model_id)
order_by = filter.get('order_by')
direction = filter.get('direction')
@ -258,9 +250,9 @@ class FeedbackTable:
else:
stmt = stmt.order_by(Feedback.created_at.desc())
# Count before pagination without wrapping the ordered item query.
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
@ -383,45 +375,6 @@ class FeedbackTable:
return result
async def get_model_feedback_counts_by_day(
self,
model_id: str,
start_date: Optional[int] = None,
db: Optional[AsyncSession] = None,
) -> list[ModelHistoryCounts]:
"""Get aggregated feedback counts per day for a model, preserving all matching days."""
from collections import defaultdict
from datetime import datetime
async with get_async_db_context(db) as db:
stmt = select(Feedback.created_at, Feedback.data).filter(Feedback.data['model_id'].as_string() == model_id)
if start_date is not None:
stmt = stmt.filter(Feedback.created_at >= start_date)
result = await db.execute(stmt.order_by(Feedback.created_at.asc()))
rows = result.all()
daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0})
for created_at, data in rows:
if not data:
continue
rating_str = str(data.get('rating', ''))
if rating_str not in ('1', '-1'):
continue
date_str = datetime.fromtimestamp(created_at).strftime('%Y-%m-%d')
if rating_str == '1':
daily_counts[date_str]['won'] += 1
else:
daily_counts[date_str]['lost'] += 1
return [
ModelHistoryCounts(date=date_str, won=counts['won'], lost=counts['lost'])
for date_str, counts in sorted(daily_counts.items())
]
async def get_feedbacks_by_type(self, type: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()))

View file

@ -142,6 +142,7 @@ class FilesTable:
result = File(**file.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FileModel.model_validate(result)
else:
@ -200,18 +201,6 @@ class FilesTable:
result = await db.execute(select(File))
return [FileModel.model_validate(file) for file in result.scalars().all()]
async def count_files_by_user_id(
self,
user_id: str | None = None,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as db:
stmt = select(func.count(File.id))
if user_id:
stmt = stmt.filter_by(user_id=user_id)
result = await db.execute(stmt)
return result.scalar() or 0
async def check_access_by_user_id(self, id, user_id, permission='write', db: AsyncSession | None = None) -> bool:
file = await self.get_file_by_id(id, db=db)
if not file:

View file

@ -6,7 +6,7 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select, or_, and_
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@ -58,21 +58,6 @@ class FolderNameIdResponse(BaseModel):
meta: Optional[FolderMetadataResponse] = None
parent_id: Optional[str] = None
is_expanded: bool = False
unread_count: int = 0
created_at: int
updated_at: int
class SharedFolderResponse(BaseModel):
id: str
name: str
parent_id: Optional[str] = None
user_id: str
owner_name: Optional[str] = None
permission: str = 'read'
access_grants: list = []
is_expanded: bool = False
meta: Optional[dict] = None
created_at: int
updated_at: int
@ -145,71 +130,16 @@ class FolderTable:
except Exception:
return None
async def get_folder_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FolderModel]:
"""Fetch folder by ID only (no user_id filter). Used for shared access."""
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id))
folder = result.scalars().first()
if not folder:
return None
return FolderModel.model_validate(folder)
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]:
"""
Returns {folder_id: highest_permission} for all folders shared with user.
Checks direct user grants, group grants, and public (user:*) grants.
"""
from open_webui.models.access_grants import AccessGrant
async with get_async_db_context(db) as db:
conditions = [
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'),
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id),
]
if user_group_ids:
conditions.append(
and_(AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(user_group_ids))
)
result = await db.execute(
select(AccessGrant).filter(
AccessGrant.resource_type == 'folder',
or_(*conditions),
)
)
grants = result.scalars().all()
# Build {folder_id: highest_permission} ('write' > 'read')
folder_perms = {}
for g in grants:
existing = folder_perms.get(g.resource_id)
if existing != 'write':
folder_perms[g.resource_id] = g.permission
return folder_perms
async def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[list[FolderModel]]:
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)
@ -239,9 +169,7 @@ 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(func.lower(Folder.name) == func.lower(name))
select(Folder).filter_by(parent_id=parent_id, user_id=user_id).filter(Folder.name.ilike(name))
)
folder = result.scalars().first()
@ -257,32 +185,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).order_by(Folder.updated_at.desc())
)
result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id))
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
async def get_folder_ids_by_id_and_user_id_in_subtree(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> list[str]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
folder = result.scalars().first()
if not folder:
return []
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)
for child in children:
if child.id not in folder_ids:
folder_ids.add(child.id)
folders.append(child)
return list(folder_ids)
async def update_folder_parent_id_by_id_and_user_id(
self,
id: str,
@ -391,15 +296,11 @@ 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)

View file

@ -7,8 +7,7 @@ import time
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import User, UserResponse, Users, UserSettings
from open_webui.utils.valves import decrypt_valves, encrypt_valves
from open_webui.models.users import UserModel, UserResponse, Users
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -42,7 +41,7 @@ class FunctionMeta(BaseModel):
class FunctionModel(BaseModel):
id: str
user_id: str | None = None # may be null for legacy/malformed records
user_id: str
name: str
type: str
content: str
@ -58,7 +57,7 @@ class FunctionModel(BaseModel):
# --- form / schema definitions ---
class FunctionWithValvesModel(BaseModel):
id: str
user_id: str | None = None # may be null for legacy/malformed records
user_id: str
name: str
type: str
content: str
@ -79,7 +78,7 @@ class FunctionWithValvesModel(BaseModel):
class FunctionResponse(BaseModel):
id: str
user_id: str | None = None # may be null for legacy/malformed records
user_id: str
type: str
name: str
meta: FunctionMeta
@ -129,6 +128,7 @@ class FunctionsTable:
result = Function(**function.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FunctionModel.model_validate(result)
else:
@ -143,8 +143,7 @@ class FunctionsTable:
functions: list[FunctionWithValvesModel],
db: AsyncSession | None = None,
) -> list[FunctionWithValvesModel]:
# Synchronize functions by updating existing ones, inserting new ones,
# and removing those that are no longer present.
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try:
async with get_async_db_context(db) as db:
# Get existing functions
@ -157,15 +156,24 @@ class FunctionsTable:
# Update or insert functions
for func in functions:
func_data = func.model_dump()
func_data['valves'] = encrypt_valves(func_data['valves']) if func_data.get('valves') else None
func_data['user_id'] = user_id
func_data['updated_at'] = int(time.time())
if func.id in existing_ids:
await db.execute(update(Function).filter_by(id=func.id).values(**func_data))
await db.execute(
update(Function)
.filter_by(id=func.id)
.values(
**func.model_dump(),
user_id=user_id,
updated_at=int(time.time()),
)
)
else:
new_func = Function(**func_data)
new_func = Function(
**{
**func.model_dump(),
'user_id': user_id,
'updated_at': int(time.time()),
}
)
db.add(new_func)
# Remove functions that are no longer present
@ -219,15 +227,7 @@ class FunctionsTable:
functions = result.scalars().all()
if include_valves:
return [
FunctionWithValvesModel.model_validate(
{
**FunctionModel.model_validate(function).model_dump(),
'valves': decrypt_valves(function.valves),
}
)
for function in functions
]
return [FunctionWithValvesModel.model_validate(function) for function in functions]
else:
return [FunctionModel.model_validate(function) for function in functions]
@ -274,18 +274,6 @@ class FunctionsTable:
result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True))
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
async def get_active_function_ids_by_type(
self, type: str, db: AsyncSession | None = None
) -> list[tuple[str, bool]]:
"""Return (id, is_global) for active functions without fetching plugin source."""
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.is_global).filter_by(type=type, is_active=True))
return [(id, bool(is_global)) for id, is_global in result.all()]
async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]:
"""Return (id, is_global) for active filters without fetching plugin source."""
return await self.get_active_function_ids_by_type('filter', db=db)
async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True))
@ -294,8 +282,8 @@ class FunctionsTable:
async def get_function_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
async with get_async_db_context(db) as db:
try:
result = await db.execute(select(Function.valves).filter_by(id=id))
return decrypt_valves(result.scalar_one_or_none())
function = await db.get(Function, id)
return function.valves if function.valves else {}
except Exception as e:
log.exception(f'Error getting function valves by id {id}: {e}')
return None
@ -311,7 +299,8 @@ class FunctionsTable:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids)))
return {id: decrypt_valves(valves) for id, valves in result.all()}
functions = result.all()
return {f.id: (f.valves if f.valves else {}) for f in functions}
except Exception as e:
log.exception(f'Error batch-fetching function valves: {e}')
return {}
@ -322,9 +311,10 @@ class FunctionsTable:
async with get_async_db_context(db) as db:
try:
function = await db.get(Function, id)
function.valves = encrypt_valves(valves)
function.valves = valves
function.updated_at = int(time.time())
await db.commit()
await db.refresh(function)
return FunctionModel.model_validate(function)
except Exception:
return None
@ -344,6 +334,7 @@ class FunctionsTable:
function.updated_at = int(time.time())
await db.commit()
await db.refresh(function)
return FunctionModel.model_validate(function)
else:
return None
@ -355,11 +346,8 @@ class FunctionsTable:
self, id: str, user_id: str, db: AsyncSession | None = None
) -> dict | None:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(User.settings).filter_by(id=user_id))
settings = result.scalar_one_or_none()
user_settings = UserSettings(**settings).model_dump() if settings else {}
user = await Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "functions" and "valves" settings
if 'functions' not in user_settings:
@ -367,8 +355,8 @@ class FunctionsTable:
if 'valves' not in user_settings['functions']:
user_settings['functions']['valves'] = {}
return decrypt_valves(user_settings['functions']['valves'].get(id))
except Exception:
return user_settings['functions']['valves'].get(id, {})
except Exception as e:
log.exception(f'Error getting user values by id {id} and user id {user_id}')
return None
@ -385,12 +373,12 @@ class FunctionsTable:
if 'valves' not in user_settings['functions']:
user_settings['functions']['valves'] = {}
user_settings['functions']['valves'][id] = encrypt_valves(valves)
user_settings['functions']['valves'][id] = valves
# Update the user settings in the database
await Users.update_user_by_id(user_id, {'settings': user_settings}, db=db)
return valves
return user_settings['functions']['valves'][id]
except Exception as e:
log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}')
return None

View file

@ -1,3 +1,4 @@
import json
import logging
import time
import uuid
@ -12,7 +13,6 @@ from sqlalchemy import (
BigInteger,
Column,
ForeignKey,
Index,
String,
Text,
and_,
@ -72,8 +72,6 @@ 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(

View file

@ -1,9 +1,9 @@
import json
import logging
import time
import uuid
from typing import Optional
from open_webui.config import RAG_FILE_CONTENT_SEARCH_MAX_CHARS
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.files import (
@ -31,13 +31,9 @@ from sqlalchemy import (
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import defer
log = logging.getLogger(__name__)
# Columns the knowledge base list may be ordered by; anything else falls back to the default.
KNOWLEDGE_SORTABLE_FIELDS = {'name', 'created_at', 'updated_at'}
####################
# Knowledge DB Schema
# Let what was gathered here outlast the one who gathered it,
@ -151,7 +147,6 @@ class KnowledgeDirectoryForm(BaseModel):
####################
class KnowledgeUserModel(KnowledgeModel):
user: Optional[UserResponse] = None
file_count: int | None = None
class KnowledgeResponse(KnowledgeModel):
@ -194,11 +189,11 @@ class KnowledgeTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> KnowledgeModel:
knowledge_model = KnowledgeModel.model_validate(knowledge)
knowledge_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(knowledge_model.id, db=db)
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(exclude={'access_grants'})
knowledge_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(knowledge_data['id'], db=db)
)
return knowledge_model
return KnowledgeModel.model_validate(knowledge_data)
async def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm, db: Optional[AsyncSession] = None
@ -291,17 +286,6 @@ class KnowledgeTable:
elif view_option == 'shared':
stmt = stmt.filter(Knowledge.user_id != user_id)
source = filter.get('source')
if source == 'external':
stmt = stmt.filter(Knowledge.meta['source'].as_string() == 'external')
elif source == 'local':
stmt = stmt.filter(
or_(
Knowledge.meta.is_(None),
Knowledge.meta['source'].as_string() != 'external',
)
)
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,
@ -311,17 +295,7 @@ class KnowledgeTable:
permission='read',
)
order_by = (filter or {}).get('order_by')
direction = (filter or {}).get('direction')
if order_by in KNOWLEDGE_SORTABLE_FIELDS:
column = getattr(Knowledge, order_by)
if (direction or 'desc').lower() == 'asc':
stmt = stmt.order_by(column.asc(), Knowledge.id.asc())
else:
stmt = stmt.order_by(column.desc(), Knowledge.id.asc())
else:
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
@ -335,14 +309,6 @@ class KnowledgeTable:
knowledge_ids = [kb.id for kb, _ in items]
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
file_counts = {}
if knowledge_ids:
file_count_result = await db.execute(
select(KnowledgeFile.knowledge_id, func.count(KnowledgeFile.id))
.where(KnowledgeFile.knowledge_id.in_(knowledge_ids))
.group_by(KnowledgeFile.knowledge_id)
)
file_counts = dict(file_count_result.all())
knowledge_bases = []
for knowledge_base, user in items:
@ -357,7 +323,6 @@ class KnowledgeTable:
)
).model_dump(),
'user': (UserModel.model_validate(user).model_dump() if user else None),
'file_count': file_counts.get(knowledge_base.id, 0),
}
)
)
@ -404,7 +369,6 @@ class KnowledgeTable:
# to avoid PostgreSQL "invalid memory alloc request
# size" on large extracted-content rows (#24670).
content_text = File.data['content'].as_string()
content_text = func.substr(content_text, 1, RAG_FILE_CONTENT_SEARCH_MAX_CHARS)
search_filter = or_(
File.filename.ilike(f'%{q}%'),
content_text.ilike(f'%{q}%'),
@ -441,7 +405,6 @@ class KnowledgeTable:
if limit:
stmt = stmt.limit(limit)
stmt = stmt.options(defer(File.data))
result = await db.execute(stmt)
rows = result.all()
@ -449,13 +412,7 @@ class KnowledgeTable:
for file, user, knowledge in rows:
items.append(
FileUserResponse(
id=file.id,
user_id=file.user_id,
hash=file.hash,
filename=file.filename,
meta=file.meta,
created_at=file.created_at,
updated_at=file.updated_at,
**FileModel.model_validate(file).model_dump(),
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
collection=(await self._to_knowledge_model(knowledge, db=db)).model_dump(),
)
@ -467,22 +424,14 @@ 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,
user_group_ids: set[str] | None = None,
) -> bool:
async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = 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
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}
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',
@ -492,6 +441,28 @@ 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}
result = []
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user_id:
result.append(knowledge_base)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(knowledge_base)
return result
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:
@ -501,6 +472,29 @@ 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:
@ -560,7 +554,6 @@ class KnowledgeTable:
# to avoid PostgreSQL memory allocation failures on
# large content (#24670).
content_text = File.data['content'].as_string()
content_text = func.substr(content_text, 1, RAG_FILE_CONTENT_SEARCH_MAX_CHARS)
stmt = stmt.filter(
or_(
File.filename.ilike(f'%{query_key}%'),
@ -599,23 +592,17 @@ class KnowledgeTable:
if limit:
stmt = stmt.limit(limit)
stmt = stmt.options(defer(File.data))
result = await db.execute(stmt)
items = result.all()
files = [
FileUserResponse(
id=file.id,
user_id=file.user_id,
hash=file.hash,
filename=file.filename,
meta=file.meta,
created_at=file.created_at,
updated_at=file.updated_at,
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
files = []
for file, user in items:
files.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
for file, user in items
]
return KnowledgeFileListResponse(
items=files,
@ -650,25 +637,9 @@ class KnowledgeTable:
async def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[AsyncSession] = None
) -> list[FileMetadataResponse]:
"""Column-only listing: File.data holds each file's full extracted
text, which metadata views must never load."""
try:
async with get_async_db_context(db) as db:
result = await db.execute(
select(File.id, File.hash, File.meta, File.created_at, File.updated_at)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
return [
FileMetadataResponse(
id=row.id,
hash=row.hash,
meta=row.meta,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in result.all()
]
files = await self.get_files_by_id(knowledge_id, db=db)
return [FileMetadataResponse(**file.model_dump()) for file in files]
except Exception:
return []
@ -775,8 +746,8 @@ class KnowledgeTable:
log.exception(e)
return None
async def update_knowledge_meta_by_id(
self, id: str, meta: dict, db: Optional[AsyncSession] = 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:
@ -784,7 +755,7 @@ class KnowledgeTable:
update(Knowledge)
.filter_by(id=id)
.values(
meta=meta,
data=data,
updated_at=int(time.time()),
)
)

View file

@ -4,11 +4,11 @@ from __future__ import annotations
import time
import uuid
from typing import Literal
from typing import Optional
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Column, Index, String, Text, delete, select
from sqlalchemy import BigInteger, Column, String, Text, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -16,14 +16,10 @@ class Memory(Base): # user memory store
"""Stores user-created memory entries linked to a vector collection."""
__tablename__ = 'memory'
__table_args__ = (Index('ix_memory_id_user_id', 'id', 'user_id'),)
id = Column(String, primary_key=True, unique=True)
user_id = Column(String, index=True)
type = Column(String, default='context', server_default='context', index=True)
path = Column(Text, nullable=True)
content = Column(Text) # free-form text learned from conversation
meta = Column(JSON, nullable=True)
updated_at = Column(BigInteger) # epoch seconds
created_at = Column(BigInteger) # epoch seconds
@ -33,27 +29,17 @@ class MemoryModel(BaseModel):
id: str
user_id: str
type: Literal['user', 'context'] = 'context'
path: str | None = None
content: str
meta: dict | None = None
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True) # allows ORM mapping
class MemoriesTable:
@staticmethod
def normalize_memory_type(memory_type: str | None = None) -> str:
return 'user' if memory_type == 'user' else 'context'
async def insert_new_memory(
self,
user_id: str,
content: str,
memory_type: str | None = None,
path: str | None = None,
meta: dict | None = None,
db: AsyncSession | None = None,
) -> MemoryModel | None:
"""Persist a new memory entry and return the created model."""
@ -62,26 +48,20 @@ class MemoriesTable:
record = Memory(
id=str(uuid.uuid4()),
user_id=user_id,
type=self.normalize_memory_type(memory_type),
path=path,
content=content,
meta=meta,
created_at=now,
updated_at=now,
)
db.add(record)
await db.commit()
await db.refresh(record)
return MemoryModel.model_validate(record) if record else None
async def update_memory_by_id_and_user_id(
self,
id: str,
user_id: str,
content: str | None,
memory_type: str | None = None,
path: str | None = None,
update_path: bool = False,
meta: dict | None = None,
content: str,
db: AsyncSession | None = None,
) -> MemoryModel | None:
async with get_async_db_context(db) as db:
@ -90,17 +70,11 @@ class MemoriesTable:
if not memory or memory.user_id != user_id:
return None
if content is not None:
memory.content = content
if memory_type is not None:
memory.type = self.normalize_memory_type(memory_type)
if update_path:
memory.path = path
if meta is not None:
memory.meta = {**(memory.meta or {}), **meta}
memory.content = content
memory.updated_at = int(time.time())
await db.commit()
await db.refresh(memory)
return MemoryModel.model_validate(memory)
except Exception:
return None
@ -165,104 +139,5 @@ class MemoriesTable:
except Exception:
return False
async def apply_memory_operations(
self,
user_id: str,
operations: list[dict],
db: AsyncSession | None = None,
) -> list[dict]:
now = int(time.time())
results: list[dict] = []
async with get_async_db_context(db) as db:
for operation in operations:
action = operation.get('action')
if action == 'add':
content = operation.get('content', '').strip()
memory_type = self.normalize_memory_type(operation.get('type'))
path = operation.get('path')
result = await db.execute(
select(Memory).filter_by(user_id=user_id, content=content, type=memory_type, path=path)
)
existing = result.scalars().first()
if existing:
results.append(
{
'action': action,
'status': 'skipped',
'memory': MemoryModel.model_validate(existing),
'reason': 'duplicate',
}
)
continue
memory = Memory(
id=str(uuid.uuid4()),
user_id=user_id,
type=memory_type,
path=path,
content=content,
meta=operation.get('meta'),
created_at=now,
updated_at=now,
)
db.add(memory)
await db.flush()
results.append(
{'action': action, 'status': 'created', 'memory': MemoryModel.model_validate(memory)}
)
elif action == 'replace':
memory_id = operation.get('id')
content = operation.get('content', '').strip()
memory = await db.get(Memory, memory_id)
if not memory or memory.user_id != user_id:
raise ValueError(f'Memory not found: {memory_id}')
memory.content = content
if operation.get('type') is not None:
memory.type = self.normalize_memory_type(operation.get('type'))
if 'path' in operation:
memory.path = operation.get('path')
if operation.get('meta') is not None:
memory.meta = {**(memory.meta or {}), **operation.get('meta')}
memory.updated_at = now
await db.flush()
results.append(
{'action': action, 'status': 'updated', 'memory': MemoryModel.model_validate(memory)}
)
elif action == 'move':
memory_id = operation.get('id')
memory = await db.get(Memory, memory_id)
if not memory or memory.user_id != user_id:
raise ValueError(f'Memory not found: {memory_id}')
memory.path = operation.get('path')
if operation.get('meta') is not None:
memory.meta = {**(memory.meta or {}), **operation.get('meta')}
memory.updated_at = now
await db.flush()
results.append(
{'action': action, 'status': 'updated', 'memory': MemoryModel.model_validate(memory)}
)
elif action == 'remove':
memory_id = operation.get('id')
memory = await db.get(Memory, memory_id)
if not memory or memory.user_id != user_id:
raise ValueError(f'Memory not found: {memory_id}')
await db.delete(memory)
results.append({'action': action, 'status': 'deleted', 'id': memory_id})
else:
raise ValueError(f'Unsupported memory operation: {action}')
await db.commit()
return results
Memories = MemoriesTable() # user memory registry

View file

@ -1,3 +1,4 @@
import json
import time
import uuid
from typing import Optional
@ -327,8 +328,7 @@ class MessageTable:
async with get_async_db_context(db) as db:
message = await db.get(Message, parent_id)
# Thread parent must belong to the requested channel; never disclose a foreign-channel message.
if not message or message.channel_id != channel_id:
if not message:
return []
result = await db.execute(
@ -500,71 +500,6 @@ class MessageTable:
return [Reactions(**reaction) for reaction in reactions.values()]
async def get_reactions_by_message_ids(
self, ids: list[str], db: Optional[AsyncSession] = None
) -> dict[str, list[Reactions]]:
"""Batch-fetch reactions for multiple messages in a single query.
Returns a dict mapping each message_id to its list of Reactions.
Messages with no reactions map to an empty list.
"""
if not ids:
return {}
async with get_async_db_context(db) as db:
result = await db.execute(
select(MessageReaction, User)
.join(User, MessageReaction.user_id == User.id)
.filter(MessageReaction.message_id.in_(ids))
)
rows = result.all()
# Group by (message_id, reaction_name)
grouped: dict[str, dict[str, dict]] = {mid: {} for mid in ids}
for reaction, user in rows:
mid = reaction.message_id
if mid not in grouped:
grouped[mid] = {}
if reaction.name not in grouped[mid]:
grouped[mid][reaction.name] = {
'name': reaction.name,
'users': [],
'count': 0,
}
grouped[mid][reaction.name]['users'].append(
{
'id': user.id,
'name': user.name,
}
)
grouped[mid][reaction.name]['count'] += 1
return {mid: [Reactions(**r) for r in reactions.values()] for mid, reactions in grouped.items()}
async def get_thread_reply_counts_by_message_ids(
self, ids: list[str], db: Optional[AsyncSession] = None
) -> dict[str, tuple[int, int | None]]:
"""Batch-fetch reply counts and latest reply timestamps for multiple parent messages.
Returns a dict mapping each parent message_id to a
(reply_count, latest_reply_created_at) tuple.
Messages with no replies are omitted from the result.
"""
if not ids:
return {}
async with get_async_db_context(db) as db:
result = await db.execute(
select(
Message.parent_id,
func.count(Message.id),
func.max(Message.created_at),
)
.filter(Message.parent_id.in_(ids))
.group_by(Message.parent_id)
)
return {row[0]: (row[1], row[2]) for row in result.all()}
async def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None
) -> bool:

View file

@ -1,18 +1,18 @@
from __future__ import annotations
import json
import logging
import time
from copy import deepcopy
from typing import Any
from typing import Optional
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__)
@ -22,50 +22,6 @@ 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):
return knowledge
sanitized = []
for item in knowledge:
if not isinstance(item, dict):
sanitized.append(item)
continue
next_item = item
data = item.get('data')
if isinstance(data, dict) and 'content' in data:
next_item = deepcopy(item)
next_item.get('data', {}).pop('content', None)
file = next_item.get('file')
file_data = file.get('data') if isinstance(file, dict) else None
if isinstance(file_data, dict) and 'content' in file_data:
if next_item is item:
next_item = deepcopy(item)
file = next_item.get('file')
file_data = file.get('data') if isinstance(file, dict) else None
file_data.pop('content', None)
sanitized.append(next_item)
return sanitized
# --- Models DB Schema ---
@ -81,7 +37,6 @@ class ModelMeta(BaseModel):
profile_image_url: str | None = None
description: str | None = Field(default=None, description='User-facing description of the model.')
capabilities: dict | None = None
knowledge: list[Any] | None = None
model_config = ConfigDict(extra='allow')
@ -101,16 +56,19 @@ class ModelMeta(BaseModel):
)
return None
@field_validator('knowledge', mode='before')
@classmethod
def strip_knowledge_content(cls, v):
return strip_extracted_content_from_model_knowledge(v)
@model_validator(mode='before')
@classmethod
def normalize_tags(cls, data):
if isinstance(data, dict) and 'tags' in data:
data['tags'] = normalize_model_tags(data['tags'])
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
return data
@ -194,19 +152,11 @@ class ModelsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> ModelModel:
if isinstance(model.meta, dict):
knowledge = model.meta.get('knowledge')
stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge)
if stripped_knowledge != knowledge:
model.meta = {**model.meta, 'knowledge': stripped_knowledge}
if db is not None:
await db.commit()
model_model = ModelModel.model_validate(model)
model_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(model_model.id, db=db)
model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'})
model_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db)
)
return model_model
return ModelModel.model_validate(model_data)
async def insert_new_model(
self, form_data: ModelForm, user_id: str, db: AsyncSession | None = None
@ -223,6 +173,7 @@ class ModelsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db)
if result:
@ -247,21 +198,9 @@ 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, writable_by_user_id: str | None = None, db: AsyncSession | None = None
) -> list[ModelUserResponse]:
async def get_models(self, db: AsyncSession | None = None) -> list[ModelUserResponse]:
async with get_async_db_context(db) as db:
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)
result = await db.execute(select(Model).filter(Model.base_model_id != None))
all_models = result.scalars().all()
user_ids = list(set(model.user_id for model in all_models))
@ -290,43 +229,10 @@ 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 def get_base_models(self, db: AsyncSession | None = None) -> list[ModelModel]:
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:
return False
for raw_tag in meta.get('tags', []):
name = raw_tag.get('name') if isinstance(raw_tag, dict) else str(raw_tag)
if name == tag:
return True
return False
async def get_base_models(self, tag: str | None = None, db: AsyncSession | None = None) -> list[ModelModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter(Model.base_model_id.is_(None)))
result = await db.execute(select(Model).filter(Model.base_model_id == None))
all_models = result.scalars().all()
if tag:
all_models = [model for model in all_models if self._meta_has_tag(model.meta, tag)]
model_ids = [model.id for model in all_models]
grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db)
return [
@ -334,6 +240,28 @@ 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
) -> list[ModelUserResponse]:
models = await self.get_models(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 model in models:
if model.user_id == user_id:
result.append(model)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='model',
resource_id=model.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(model)
return result
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
db=db,
@ -385,14 +313,20 @@ class ModelsTable:
tag = filter.get('tag')
if 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)
# 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)}%'
else:
meta_text = func.lower(cast(Model.meta, String))
variants = json_text_variants(tag.lower())
stmt = stmt.filter(or_(*(meta_text.like(f'%"{variant}"%') for variant in variants)))
pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%'
stmt = stmt.filter(meta_text.like(pattern))
order_by = filter.get('order_by')
direction = filter.get('direction')
@ -461,14 +395,11 @@ class ModelsTable:
self,
user_id: str,
is_admin: bool = False,
is_base_model: bool = False,
db: AsyncSession | None = None,
) -> set[str]:
"""Extract unique tag names from model meta, querying only the meta column."""
async with get_async_db_context(db) as db:
stmt = select(Model.meta).filter(
Model.base_model_id.is_(None) if is_base_model else Model.base_model_id.is_not(None)
)
stmt = select(Model.meta).filter(Model.base_model_id != None)
if not is_admin:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
@ -534,6 +465,7 @@ class ModelsTable:
model.is_active = not model.is_active
model.updated_at = int(time.time())
await db.commit()
await db.refresh(model)
return await self._to_model_model(model, db=db)
except Exception:
@ -560,12 +492,13 @@ class ModelsTable:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter_by(id=id))
model = result.scalars().first()
if not model:
model_obj = result.scalars().first()
if not model_obj:
return None
model.updated_at = int(time.time())
model_obj.updated_at = int(time.time())
await db.commit()
return await self._to_model_model(model, db=db)
await db.refresh(model_obj)
return await self._to_model_model(model_obj, db=db)
except Exception as e:
log.exception(f'Failed to update the model updated_at by id {id}: {e}')
return None
@ -610,16 +543,25 @@ 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_data))
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()),
)
)
else:
db.add(Model(**model_data))
new_model = Model(
**{
**model.model_dump(exclude={'access_grants'}),
'user_id': user_id,
'updated_at': int(time.time()),
}
)
db.add(new_model)
await AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db)
# Remove models that are no longer present

View file

@ -1,3 +1,4 @@
import json
import time
import uuid
from functools import lru_cache
@ -7,8 +8,7 @@ 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 open_webui.utils.json_codec import JSONCodec
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import JSON, BigInteger, Boolean, Column, ForeignKey, Text, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -31,32 +31,6 @@ 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)
@ -73,11 +47,6 @@ 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'
@ -99,11 +68,6 @@ 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
@ -111,11 +75,6 @@ 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
@ -147,12 +106,11 @@ class NoteTable:
db: Optional[AsyncSession] = None,
) -> NoteModel:
# We exclude access_grants to inject them
note_model = NoteModel.model_validate(note)
note_model.data = note_model.data or {}
note_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(note_model.id, db=db)
note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'})
note_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(note_data['id'], db=db)
)
return note_model
return NoteModel.model_validate(note_data)
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
@ -346,17 +304,13 @@ 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']
if 'data' in form_data:
note.data = {**(note.data or {}), **(form_data['data'] or {})}
note.data = {**note.data, **form_data['data']}
if 'meta' in form_data:
note.meta = {**(note.meta or {}), **(form_data['meta'] or {})}
if not db.is_modified(note) and 'access_grants' not in form_data:
return await self._to_note_model(note, db=db)
note.meta = {**note.meta, **form_data['meta']}
if 'access_grants' in form_data:
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)

View file

@ -1,5 +1,6 @@
import base64
import hashlib
import json
import logging
import time
import uuid
@ -8,7 +9,6 @@ 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 = JSONCodec.dumps(token)
token_json = json.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 JSONCodec.loads(decrypted)
return json.loads(decrypted)
except Exception as e:
log.error(f'Error decrypting tokens: {type(e).__name__}: {e}')
raise
@ -128,6 +128,7 @@ class OAuthSessionTable:
db.add(result)
await db.commit()
await db.refresh(result)
if result:
# Make a copy of the model data before closing session

View file

@ -1,6 +1,7 @@
"""Prompt history model for version tracking."""
import difflib
import json
import time
import uuid
from typing import Optional
@ -69,6 +70,7 @@ class PromptHistoryTable:
)
db.add(history)
await db.commit()
await db.refresh(history)
return PromptHistoryModel.model_validate(history)
async def get_history_by_prompt_id(

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import time
import uuid
@ -14,7 +15,6 @@ 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
@ -103,11 +103,11 @@ class PromptsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> PromptModel:
prompt_model = PromptModel.model_validate(prompt)
prompt_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(prompt_model.id, db=db)
prompt_data = PromptModel.model_validate(prompt).model_dump(exclude={'access_grants'})
prompt_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(prompt_data['id'], db=db)
)
return prompt_model
return PromptModel.model_validate(prompt_data)
async def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: AsyncSession | None = None
@ -132,6 +132,7 @@ class PromptsTable:
)
session.add(record)
await session.commit()
await session.refresh(record) # populate generated defaults
await AccessGrants.set_access_grants(
'prompt',
@ -168,6 +169,7 @@ class PromptsTable:
if history_entry:
record.version_id = history_entry.id
await session.commit()
await session.refresh(record) # re-read version_id
return await self._to_prompt_model(record, db=session)
except Exception as e:
@ -342,10 +344,9 @@ class PromptsTable:
'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)'
)
else:
# 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))
# 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)}%'
)
tag_lower = None
@ -636,6 +637,7 @@ class PromptsTable:
prompt.is_active = not prompt.is_active
prompt.updated_at = int(time.time())
await session.commit()
await session.refresh(prompt)
return await self._to_prompt_model(prompt, db=session)
return None
except Exception:

View file

@ -201,15 +201,5 @@ class SharedChatsTable:
except Exception:
return False
async def delete_all_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all shared chats created by a user."""
try:
async with get_async_db_context(db) as db:
await db.execute(delete(SharedChat).filter_by(user_id=user_id))
await db.commit()
return True
except Exception:
return False
SharedChats = SharedChatsTable()

View file

@ -113,11 +113,11 @@ class SkillsTable:
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> SkillModel:
skill_model = SkillModel.model_validate(skill)
skill_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(skill_model.id, db=db)
skill_data = SkillModel.model_validate(skill).model_dump(exclude={'access_grants'})
skill_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(skill_data['id'], db=db)
)
return skill_model
return SkillModel.model_validate(skill_data)
async def insert_new_skill(
self,
@ -137,6 +137,7 @@ class SkillsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db)
if result:
return await self._to_skill_model(result, db=db)
@ -163,30 +164,9 @@ class SkillsTable:
except Exception:
return None
async def get_skills(
self,
user_id: str | None = None,
ids: list[str] | None = None,
db: AsyncSession | None = None,
) -> list[SkillUserModel]:
async def get_skills(self, db: Optional[AsyncSession] = None) -> list[SkillUserModel]:
async with get_async_db_context(db) as db:
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)
result = await db.execute(select(Skill).order_by(Skill.updated_at.desc()))
all_skills = result.scalars().all()
user_ids = list(set(skill.user_id for skill in all_skills))
@ -215,6 +195,28 @@ 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,
@ -257,26 +259,7 @@ class SkillsTable:
permission='read',
)
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(Skill.name.asc())
else:
stmt = stmt.order_by(Skill.name.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(Skill.created_at.asc())
else:
stmt = stmt.order_by(Skill.created_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Skill.updated_at.asc())
else:
stmt = stmt.order_by(Skill.updated_at.desc())
else:
stmt = stmt.order_by(Skill.updated_at.desc())
stmt = stmt.order_by(Skill.updated_at.desc())
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
@ -324,8 +307,8 @@ class SkillsTable:
if access_grants is not None:
await AccessGrants.set_access_grants('skill', id, access_grants, db=db)
# populate_existing: the Core update above bypasses any identity-map copy
skill = await db.get(Skill, id, populate_existing=True)
skill = await db.get(Skill, id)
await db.refresh(skill)
return await self._to_skill_model(skill, db=db)
except Exception:
return None
@ -341,6 +324,7 @@ class SkillsTable:
skill.is_active = not skill.is_active
skill.updated_at = int(time.time())
await db.commit()
await db.refresh(skill)
return await self._to_skill_model(skill, db=db)
except Exception:

View file

@ -63,6 +63,7 @@ class TagTable:
record = Tag(id=tag_id, user_id=user_id, name=name)
db.add(record)
await db.commit()
await db.refresh(record)
return TagModel.model_validate(record) if record else None
except Exception as e:
log.exception('Error inserting tag %r: %s', name, e)
@ -97,7 +98,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('res: %s', result.rowcount)
log.debug(f'res: {result.rowcount}')
await db.commit()
return True
except Exception as e:

View file

@ -10,7 +10,6 @@ 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 UserResponse, Users
from open_webui.utils.valves import decrypt_valves, encrypt_valves
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, String, Text, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -36,15 +35,13 @@ class Tool(Base): # database table definition
class ToolMeta(BaseModel):
description: str | None = None
manifest: dict | None = {}
has_user_valves: bool = False
class ToolModel(BaseModel):
id: str
user_id: str | None = None # may be null for legacy/malformed records
user_id: str
name: str
# None when listed with defer_content=True (source skipped for listings)
content: str | None = None
content: str
specs: list[dict]
meta: ToolMeta
access_grants: list[AccessGrantModel] = Field(default_factory=list)
@ -66,7 +63,7 @@ class ToolUserModel(ToolModel):
class ToolResponse(BaseModel):
id: str
user_id: str | None = None # may be null for legacy/malformed records
user_id: str
name: str
meta: ToolMeta
access_grants: list[AccessGrantModel] = Field(default_factory=list)
@ -106,11 +103,11 @@ class ToolsTable:
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> ToolModel:
tool_model = ToolModel.model_validate(tool)
tool_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(tool_model.id, db=db)
tool_data = ToolModel.model_validate(tool).model_dump(exclude={'access_grants'})
tool_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(tool_data['id'], db=db)
)
return tool_model
return ToolModel.model_validate(tool_data)
async def insert_new_tool(
self,
@ -132,6 +129,7 @@ class ToolsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db)
if result:
return await self._to_tool_model(result, db=db)
@ -169,37 +167,13 @@ class ToolsTable:
for tool in tools
}
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 def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]:
async with get_async_db_context(db) as db:
# 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,
)
stmt = select(Tool).order_by(Tool.updated_at.desc())
if defer_content:
stmt = stmt
result = await db.execute(stmt)
all_tools = result.all() if defer_content else result.scalars().all()
all_tools = result.scalars().all()
user_ids = list(set(tool.user_id for tool in all_tools))
tool_ids = [tool.id for tool in all_tools]
@ -234,22 +208,31 @@ 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}
return await self.get_tools(
defer_content=defer_content,
db=db,
user_id=user_id,
user_group_ids=user_group_ids,
permission=permission,
)
result = []
for tool in tools:
if tool.user_id == user_id:
result.append(tool)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='tool',
resource_id=tool.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(tool)
return result
async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
try:
async with get_async_db_context(db) as db:
tool = await db.get(Tool, id)
return decrypt_valves(tool.valves if tool else None)
except Exception:
return tool.valves if tool.valves else {}
except Exception as e:
log.exception(f'Error getting tool valves by id {id}')
return None
@ -258,9 +241,7 @@ class ToolsTable:
) -> ToolValves | None:
try:
async with get_async_db_context(db) as db:
await db.execute(
update(Tool).filter_by(id=id).values(valves=encrypt_valves(valves), updated_at=int(time.time()))
)
await db.execute(update(Tool).filter_by(id=id).values(valves=valves, updated_at=int(time.time())))
await db.commit()
return await self.get_tool_by_id(id, db=db)
except Exception:
@ -279,7 +260,7 @@ class ToolsTable:
if 'valves' not in user_settings['tools']:
user_settings['tools']['valves'] = {}
return decrypt_valves(user_settings['tools']['valves'].get(id))
return user_settings['tools']['valves'].get(id, {})
except Exception as e:
log.exception(f'Error getting user values by id {id} and user_id {user_id}: {e}')
return None
@ -297,12 +278,12 @@ class ToolsTable:
if 'valves' not in user_settings['tools']:
user_settings['tools']['valves'] = {}
user_settings['tools']['valves'][id] = encrypt_valves(valves)
user_settings['tools']['valves'][id] = valves
# Update the user settings in the database
await Users.update_user_by_id(user_id, {'settings': user_settings}, db=db)
return valves
return user_settings['tools']['valves'][id]
except Exception as e:
log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}')
return None
@ -316,8 +297,8 @@ class ToolsTable:
if access_grants is not None:
await AccessGrants.set_access_grants('tool', id, access_grants, db=db)
# populate_existing: the Core update above bypasses any identity-map copy
tool = await db.get(Tool, id, populate_existing=True)
tool = await db.get(Tool, id)
await db.refresh(tool)
return await self._to_tool_model(tool, db=db)
except Exception:
return None

View file

@ -9,7 +9,7 @@ from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.utils.misc import throttle
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import (
JSON,
BigInteger,
@ -27,6 +27,7 @@ from sqlalchemy import (
select,
update,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
####################
@ -68,7 +69,6 @@ class User(Base): # identity & profile
# Metadata
info = Column(JSON, nullable=True)
variables = Column(JSON, nullable=True)
settings = Column(JSON, nullable=True)
oauth = Column(JSON, nullable=True)
scim = Column(JSON, nullable=True)
@ -105,7 +105,6 @@ class UserModel(BaseModel):
status_expires_at: int | None = None
info: dict | None = None
variables: dict = Field(default_factory=dict, exclude=True)
settings: UserSettings | None = None
oauth: dict | None = None
@ -127,11 +126,6 @@ class UserModel(BaseModel):
self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id)
return self
@field_validator('variables', mode='before')
@classmethod
def normalize_variables(cls, value):
return value if isinstance(value, dict) else {}
class UserStatusModel(UserModel):
is_active: bool = False
@ -285,11 +279,6 @@ class UsersTable:
oauth: dict | None = None,
db: AsyncSession | None = None,
) -> UserModel | None:
try:
profile_image_url = validate_profile_image_url(profile_image_url)
except ValueError:
profile_image_url = '/user.png'
async with get_async_db_context(db) as session:
user = UserModel(
**{
@ -308,6 +297,7 @@ class UsersTable:
result = User(**user.model_dump())
session.add(result)
await session.commit()
await session.refresh(result)
return user if result else None
# database read methods
@ -359,15 +349,16 @@ class UsersTable:
sub: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up a user by OAuth provider + subject claim."""
sub = str(sub)
"""Look up a user by OAuth provider + subject claim (dialect-aware JSON filter)."""
async with get_async_db_context(db) as session:
# 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)))
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)
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None
@ -377,76 +368,27 @@ class UsersTable:
external_id: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up a user by SCIM provider + external ID."""
"""Look up a user by SCIM provider + external ID (dialect-aware JSON filter)."""
async with get_async_db_context(db) as session:
# 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)
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)
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None
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:
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."""
"""Paginated user listing with optional filters for role, group, and channel."""
async with get_async_db_context(db) as session:
# Deferred imports to avoid circular dependencies
from open_webui.models.channels import ChannelMember
@ -507,63 +449,64 @@ class UsersTable:
if exclude_roles:
stmt = stmt.filter(~User.role.in_(exclude_roles))
order_by = sort.get('order_by') if sort else None
direction = sort.get('direction') if sort else None
order_by = filter.get('order_by')
direction = filter.get('direction')
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())
elif not filter:
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:
stmt = stmt.order_by(User.created_at.desc())
# Count BEFORE pagination
@ -618,6 +561,13 @@ class UsersTable:
row = (await session.execute(stmt)).scalars().first()
return UserModel.model_validate(row) if row else None
async def get_user_webhook_url_by_id(self, id: str, db: AsyncSession | None = None) -> str | None:
async with get_async_db_context(db) as session:
user = await session.get(User, id)
if user and user.settings:
return user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
return None
async def get_num_users_active_today(self, db: AsyncSession | None = None) -> int | None:
async with get_async_db_context(db) as session:
current_timestamp = int(time.time())
@ -634,6 +584,7 @@ class UsersTable:
return None
user.role = role
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_status_by_id(
@ -646,6 +597,7 @@ class UsersTable:
for key, value in form_data.model_dump(exclude_none=True).items():
setattr(user, key, value)
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_profile_image_url_by_id(
@ -654,17 +606,13 @@ class UsersTable:
profile_image_url: str,
db: AsyncSession | None = None,
) -> UserModel | None:
try:
profile_image_url = validate_profile_image_url(profile_image_url)
except ValueError:
profile_image_url = '/user.png'
async with get_async_db_context(db) as session:
user = await session.get(User, id)
if user is None:
return None
user.profile_image_url = profile_image_url
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
@ -682,19 +630,17 @@ class UsersTable:
if not user:
return None
oauth = dict(user.oauth or {})
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
oauth[provider] = {'sub': sub}
user.oauth = oauth
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_scim_by_id(
self,
id: str,
provider: str,
external_id: str | None,
external_id: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Update or insert a SCIM provider/external_id pair into the user's scim JSON field."""
@ -706,6 +652,7 @@ class UsersTable:
scim[provider] = {'external_id': external_id}
user.scim = scim
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> UserModel | None:
@ -716,6 +663,7 @@ class UsersTable:
for key, value in updated.items():
setattr(user, key, value)
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
# settings update helper
@ -730,6 +678,7 @@ class UsersTable:
user_settings.update(updated)
user.settings = user_settings
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
@ -776,8 +725,8 @@ class UsersTable:
async def get_valid_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[str]:
async with get_async_db_context(db) as session:
result = await session.execute(select(User.id).where(User.id.in_(user_ids)))
return list(result.scalars().all())
result = await session.execute(select(User).where(User.id.in_(user_ids)))
return [u.id for u in result.scalars().all()]
async def get_super_admin_user(self, db: AsyncSession | None = None) -> UserModel | None:
async with get_async_db_context(db) as session:
@ -803,11 +752,11 @@ class UsersTable:
async def is_user_active(self, user_id: str, db: AsyncSession | None = None) -> bool:
async with get_async_db_context(db) as session:
last_active_at = await session.scalar(select(User.last_active_at).where(User.id == user_id))
if last_active_at:
user = await session.get(User, user_id)
if user and user.last_active_at:
# Consider user active if last_active_at within the last 3 minutes
three_minutes_ago = int(time.time()) - 180
return last_active_at >= three_minutes_ago
return user.last_active_at >= three_minutes_ago
return False

View file

@ -1,379 +0,0 @@
import asyncio
import logging
import re
import time
from typing import Any, Optional
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.models.config import Config
from open_webui.models.knowledge import KnowledgeModel
log = logging.getLogger(__name__)
EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY = 'external_knowledge.connections'
IDENTIFIER_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
async def _get_external_connection(connection_id: str) -> Optional[dict]:
connections = await Config.get(EXTERNAL_KNOWLEDGE_CONNECTIONS_CONFIG_KEY, []) or []
return next((connection for connection in connections if connection.get('id') == connection_id), None)
def _get_path(data: Any, path: Optional[str], default=None):
if not path:
return default
value = data
for part in path.split('.'):
if isinstance(value, dict):
value = value.get(part, default)
else:
return default
return value
def _normalize_result(result: dict, mapping: dict, knowledge: KnowledgeModel, distance: Optional[float] = None) -> dict:
content = _get_path(result, mapping.get('content_field', 'content'), '')
title = _get_path(result, mapping.get('title_field', 'title'), None)
source = _get_path(result, mapping.get('source_field', 'source'), None)
url = _get_path(result, mapping.get('url_field', 'url'), None)
document_id = _get_path(result, mapping.get('document_id_field', 'document_id'), None)
page = _get_path(result, mapping.get('page_field', 'page'), None)
metadata = _get_path(result, mapping.get('metadata_field', 'metadata'), {}) or {}
score = _get_path(result, mapping.get('score_field', 'score'), distance)
if not isinstance(metadata, dict):
metadata = {'external_metadata': metadata}
source_name = source or title or metadata.get('source') or metadata.get('name') or knowledge.name
metadata.update(
{
'name': title or source_name,
'source': source_name,
'url': url,
'file_id': document_id or f'external-{knowledge.id}',
'knowledge_id': knowledge.id,
'knowledge_name': knowledge.name,
'external': True,
}
)
if page is not None:
metadata['page'] = page
if document_id is not None:
metadata['document_id'] = document_id
return {
'content': content,
'metadata': metadata,
'distance': score,
}
def _source_config(knowledge: KnowledgeModel) -> dict:
external = (knowledge.meta or {}).get('external', {})
source = external.get('source') or {}
return source.get('config') or {}
def _root_field(path: Optional[str]) -> Optional[str]:
if not path:
return None
return path.split('.')[0]
def _safe_identifier(value: str, label: str) -> str:
if not value or not IDENTIFIER_RE.match(value):
raise RuntimeError(f'Invalid {label}')
return value
async def _retrieve_qdrant(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]:
try:
from qdrant_client import QdrantClient
except ImportError as exc:
raise RuntimeError('qdrant-client is not installed') from exc
if not embedding_function:
raise RuntimeError('Embedding function is not configured')
config = connection.get('config') or {}
external = (knowledge.meta or {}).get('external', {})
source = external.get('source') or {}
collection_name = source.get('name')
if not collection_name:
raise RuntimeError('External source collection is not configured')
source_config = _source_config(knowledge)
vector_field = source_config.get('vector_field') or None
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
client = QdrantClient(
url=connection.get('endpoint'),
api_key=(auth_config or {}).get('api_key'),
timeout=config.get('timeout') or 30,
)
return client.query_points(
collection_name=collection_name,
query=vector,
using=vector_field,
limit=count,
)
response = await asyncio.to_thread(_search)
mapping = {
'content_field': source_config.get('content_field') or 'payload.text',
'metadata_field': source_config.get('metadata_field') or 'payload.metadata',
'document_id_field': source_config.get('document_id_field') or 'id',
'score_field': 'score',
}
normalized = []
for point in response.points:
normalized.append(_normalize_result(point.model_dump(), mapping, knowledge, distance=point.score))
return normalized
async def _retrieve_milvus(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]:
try:
from pymilvus import MilvusClient
except ImportError as exc:
raise RuntimeError('pymilvus is not installed') from exc
if not embedding_function:
raise RuntimeError('Embedding function is not configured')
config = connection.get('config') or {}
external = (knowledge.meta or {}).get('external', {})
source = external.get('source') or {}
collection_name = source.get('name')
if not collection_name:
raise RuntimeError('Milvus collection is not configured')
source_config = _source_config(knowledge)
vector_field = source_config.get('vector_field') or 'vector'
content_field = source_config.get('content_field') or 'data.text'
metadata_field = source_config.get('metadata_field') or 'metadata'
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
client_kwargs = {
'uri': connection.get('endpoint'),
}
token = (auth_config or {}).get('api_key') or (auth_config or {}).get('token')
if token:
client_kwargs['token'] = token
if config.get('db_name'):
client_kwargs['db_name'] = config.get('db_name')
client = MilvusClient(**client_kwargs)
output_fields = {
field
for field in (
_root_field(content_field),
_root_field(metadata_field),
_root_field(source_config.get('document_id_field')),
)
if field and field != vector_field
}
kwargs = {
'collection_name': collection_name,
'data': [vector],
'anns_field': vector_field,
'limit': count,
'output_fields': list(output_fields),
}
return client.search(**kwargs)
response = await asyncio.to_thread(_search)
mapping = {
'content_field': content_field,
'metadata_field': metadata_field,
'document_id_field': source_config.get('document_id_field') or 'id',
'score_field': 'distance',
}
normalized = []
for hit in response[0] if response else []:
item = dict(hit)
entity = item.get('entity') or {}
result = {
**entity,
'id': item.get('id') or entity.get('id'),
'distance': item.get('distance'),
}
normalized.append(_normalize_result(result, mapping, knowledge, distance=item.get('distance')))
return normalized
async def _retrieve_pgvector(connection, auth_config, knowledge, query, count, embedding_function) -> list[dict]:
try:
import psycopg
from pgvector.psycopg import register_vector
from psycopg.rows import dict_row
except ImportError as exc:
raise RuntimeError('psycopg and pgvector are required for pgvector retrieval') from exc
if not embedding_function:
raise RuntimeError('Embedding function is not configured')
config = connection.get('config') or {}
external = (knowledge.meta or {}).get('external', {})
source = external.get('source') or {}
collection_name = source.get('name')
if not collection_name:
raise RuntimeError('pgvector collection is not configured')
source_config = _source_config(knowledge)
table_name = source_config.get('table_name') or 'document_chunk'
collection_field = source_config.get('collection_field') or 'collection_name'
content_field = source_config.get('content_field') or 'text'
vector_field = source_config.get('vector_field') or 'vector'
metadata_field = source_config.get('metadata_field') or 'vmetadata'
document_id_field = source_config.get('document_id_field') or 'id'
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
def _search():
from psycopg import sql
table_identifier = sql.SQL('.').join(
sql.Identifier(_safe_identifier(part, 'table name')) for part in table_name.split('.')
)
collection_identifier = sql.Identifier(_safe_identifier(collection_field, 'collection field'))
content_identifier = sql.Identifier(_safe_identifier(content_field, 'content field'))
vector_identifier = sql.Identifier(_safe_identifier(vector_field, 'vector field'))
document_id_identifier = sql.Identifier(_safe_identifier(document_id_field, 'document id field'))
metadata_sql = (
sql.Identifier(_safe_identifier(metadata_field, 'metadata field'))
if metadata_field
else sql.SQL("'{}'::jsonb")
)
with psycopg.connect(
connection.get('endpoint'),
row_factory=dict_row,
connect_timeout=config.get('timeout') or 30,
) as conn:
register_vector(conn)
with conn.cursor() as cur:
cur.execute(
sql.SQL(
"""
SELECT {document_id} AS id,
{content} AS content,
{metadata} AS metadata,
{vector_column} <=> %s AS distance
FROM {table_name}
WHERE {collection} = %s
ORDER BY distance ASC
LIMIT %s
"""
).format(
document_id=document_id_identifier,
content=content_identifier,
metadata=metadata_sql,
vector_column=vector_identifier,
table_name=table_identifier,
collection=collection_identifier,
),
(vector, collection_name, count),
)
return cur.fetchall()
rows = await asyncio.to_thread(_search)
mapping = {
'content_field': 'content',
'metadata_field': 'metadata',
'document_id_field': 'id',
'score_field': 'distance',
}
return [_normalize_result(row, mapping, knowledge, distance=row.get('distance')) for row in rows]
async def retrieve_external_knowledge(
request,
knowledge: KnowledgeModel,
queries: list[str],
count: int,
user=None,
) -> dict:
external = (knowledge.meta or {}).get('external', {})
connection_id = external.get('connection_id')
if not connection_id:
raise RuntimeError('External knowledge connection is not configured')
connection = await _get_external_connection(connection_id)
if not connection:
raise RuntimeError('External knowledge connection not found')
return await retrieve_external_knowledge_for_connection(request, knowledge, connection, queries, count, user=user)
async def retrieve_external_knowledge_for_connection(
request,
knowledge: KnowledgeModel,
connection: dict,
queries: list[str],
count: int,
user=None,
) -> dict:
auth_config = connection.get('auth_config') or {}
if not connection.get('enabled', True):
raise RuntimeError('External knowledge connection is disabled')
started_at = time.monotonic()
chunks = []
provider = (connection.get('provider') or '').lower()
for query in queries:
if provider == 'qdrant':
chunks.extend(
await _retrieve_qdrant(
connection,
auth_config,
knowledge,
query,
count,
getattr(request.app.state, 'EMBEDDING_FUNCTION', None),
)
)
elif provider == 'milvus':
chunks.extend(
await _retrieve_milvus(
connection,
auth_config,
knowledge,
query,
count,
getattr(request.app.state, 'EMBEDDING_FUNCTION', None),
)
)
elif provider == 'pgvector':
chunks.extend(
await _retrieve_pgvector(
connection,
auth_config,
knowledge,
query,
count,
getattr(request.app.state, 'EMBEDDING_FUNCTION', None),
)
)
else:
raise RuntimeError(f'Unsupported external knowledge provider: {connection.get("provider")}')
chunks = chunks[:count]
log.info(
'external_knowledge_retrieval knowledge_id=%s connection_id=%s provider=%s user_id=%s latency_ms=%s result_count=%s',
knowledge.id,
connection.get('id'),
connection.get('provider'),
getattr(user, 'id', None),
round((time.monotonic() - started_at) * 1000),
len(chunks),
)
return {
'documents': [[chunk['content'] for chunk in chunks]],
'metadatas': [[chunk['metadata'] for chunk in chunks]],
'distances': [[chunk['distance'] for chunk in chunks]],
}

View file

@ -7,7 +7,6 @@ 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__)
@ -65,6 +64,25 @@ 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)
@ -85,10 +103,7 @@ class DatalabMarkerLoader:
form_data['additional_config'] = self.additional_config
log.info(
"Datalab Marker POST request parameters: {'filename': '%s', 'mime_type': '%s', **%s}",
filename,
mime_type,
form_data,
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
)
try:
@ -152,7 +167,7 @@ class DatalabMarkerLoader:
'total_cost',
)
}
log.info('Marker processing completed successfully: %s', json.dumps(summary, indent=2))
log.info(f'Marker processing completed successfully: {json.dumps(summary, indent=2)}')
break
if status_val == 'failed' or success_val is False:
@ -219,7 +234,7 @@ class DatalabMarkerLoader:
try:
with open(output_path, 'w', encoding='utf-8') as f:
f.write(full_text)
log.info('Saved Marker output to: %s', output_path)
log.info(f'Saved Marker output to: {output_path}')
except Exception as e:
log.warning(f'Failed to write marker output to disk: {e}')
@ -234,11 +249,11 @@ class DatalabMarkerLoader:
images = final_result.get('images', {})
if images:
metadata['image_count'] = len(images)
metadata['images'] = JSONCodec.dumps(list(images.keys()))
metadata['images'] = json.dumps(list(images.keys()))
for k, v in metadata.items():
if isinstance(v, (dict, list)):
metadata[k] = JSONCodec.dumps(v)
metadata[k] = json.dumps(v)
elif v is None:
metadata[k] = ''

View file

@ -6,7 +6,7 @@ from urllib.parse import quote
import requests
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.utils.headers import include_user_info_headers, parse_custom_headers
from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
@ -19,9 +19,6 @@ class ExternalDocumentLoader(BaseLoader):
api_key: str,
mime_type=None,
user=None,
user_groups=None,
headers=None,
metadata=None,
**kwargs,
) -> None:
self.url = url
@ -31,9 +28,6 @@ class ExternalDocumentLoader(BaseLoader):
self.mime_type = mime_type
self.user = user
self.user_groups = user_groups
self.headers = headers
self.metadata = metadata
def load(self) -> List[Document]:
with open(self.file_path, 'rb') as f:
@ -51,8 +45,6 @@ class ExternalDocumentLoader(BaseLoader):
except Exception:
pass
headers.update(parse_custom_headers(self.headers, self.user, self.metadata, user_groups=self.user_groups))
if self.user is not None:
headers = include_user_info_headers(headers, self.user)

View file

@ -30,9 +30,6 @@ 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}',
},

View file

@ -1,9 +1,7 @@
import asyncio
import csv
import json
import logging
import os
import sys
import zipfile
import ftfy
import requests
@ -13,23 +11,18 @@ from langchain_community.document_loaders import (
BSHTMLLoader,
CSVLoader,
Docx2txtLoader,
OutlookMessageLoader,
PyPDFLoader,
TextLoader,
YoutubeLoader,
)
from langchain_core.documents import Document
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
GLOBAL_LOG_LEVEL,
MINERU_MAX_MARKDOWN_BYTES,
REQUESTS_VERIFY,
)
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, GLOBAL_LOG_LEVEL, REQUESTS_VERIFY
from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.external_document import ExternalDocumentLoader
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
from open_webui.retrieval.loaders.paddleocr_vl import PaddleOCRVLLoader
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -90,15 +83,6 @@ 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."""
@ -122,52 +106,6 @@ 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."""
@ -195,11 +133,10 @@ class PptxLoader:
class TikaLoader:
def __init__(self, url, file_path, mime_type=None, extract_images=None, server_version='3'):
def __init__(self, url, file_path, mime_type=None, extract_images=None):
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
@ -215,15 +152,16 @@ class TikaLoader:
if self.extract_images == True:
headers['X-Tika-PDFextractInlineImages'] = 'true'
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}'
endpoint = self.url
if not endpoint.endswith('/'):
endpoint += '/'
endpoint += 'tika/text'
r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY)
if r.ok:
raw_metadata = r.json()
text = raw_metadata.get(content_key, '<No text content found>').strip()
text = raw_metadata.get('X-TIKA:content', '<No text content found>').strip()
if 'Content-Type' in raw_metadata:
headers['Content-Type'] = raw_metadata['Content-Type']
@ -245,7 +183,6 @@ class DoclingLoader:
self.params = params or {}
def load(self) -> list[Document]:
page_break_marker = '\f'
with open(self.file_path, 'rb') as f:
headers = {}
if self.api_key:
@ -262,10 +199,6 @@ class DoclingLoader:
},
data={
'image_export_mode': 'placeholder',
'md_page_break_placeholder': page_break_marker,
# Keep Docling params as user-provided form values. Encoding nested
# values here would make Open WebUI responsible for Docling's API
# quirks and could break when Docling changes its form contract.
**self.params,
},
headers=headers,
@ -274,19 +207,9 @@ class DoclingLoader:
if r.ok:
result = r.json()
document_data = result.get('document', {})
md_content = document_data.get('md_content', '')
text = md_content or '<No text content found>'
text = document_data.get('md_content', '<No text content found>')
metadata = {'Content-Type': self.mime_type} if self.mime_type else {}
if page_break_marker in md_content:
documents = [
Document(page_content=page.strip(), metadata={**metadata, 'page': page_idx})
for page_idx, page in enumerate(md_content.split(page_break_marker))
if page.strip()
]
if documents:
log.debug('Docling extracted text: %s', text)
return documents
log.debug('Docling extracted text: %s', text)
return [Document(page_content=text, metadata=metadata)]
@ -306,8 +229,6 @@ class Loader:
def __init__(self, engine: str = '', **kwargs):
self.engine = engine
self.user = kwargs.get('user', None)
self.user_groups = kwargs.get('user_groups', None)
self.metadata = kwargs.get('metadata', {})
self.kwargs = kwargs
def load(self, filename: str, file_content_type: str, file_path: str) -> list[Document]:
@ -325,13 +246,6 @@ class Loader:
loop for the entire parse minutes for large PDFs. This offloads
the work to a worker thread so the loop stays responsive.
"""
# Group lookup is async-only, so it must happen before `load`
# is offloaded to a thread without a running event loop.
if self.engine == 'external' and self.user_groups is None:
self.user_groups = await get_user_groups_for_custom_headers(
self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), self.user
)
return await asyncio.to_thread(self.load, filename, file_content_type, file_path)
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
@ -371,20 +285,13 @@ class Loader:
try:
raw.decode('utf-8')
return 'utf-8'
except UnicodeDecodeError as e:
first_non_utf8 = e.start
except UnicodeDecodeError:
pass
# Use chardet as a hint, not as ground truth
import chardet
# chardet is pure Python (~1.3s/MB), so sample around the first bad byte
window = 256 * 1024
sample_start = max(0, first_non_utf8 - window // 2)
sample = raw[sample_start : sample_start + window]
detected = chardet.detect(sample)
# A stray byte can sit far from the real payload, leaving the sample with nothing to read
if len(sample.translate(None, delete=bytes(range(128)))) < 64 and len(sample) < len(raw):
detected = chardet.detect(raw)
detected = chardet.detect(raw)
detected_enc = (detected.get('encoding') or '').lower().replace('-', '').replace('_', '')
# Map chardet's detected encoding to the correct superset codec.
@ -486,27 +393,6 @@ 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')
@ -518,13 +404,6 @@ class Loader:
api_key=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY'),
mime_type=file_content_type,
user=self.user,
user_groups=self.user_groups,
headers=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'),
metadata={
**self.metadata,
'file_name': filename,
'file_content_type': file_content_type,
},
)
elif self.engine == 'tika' and self.kwargs.get('TIKA_SERVER_URL'):
if self._is_text_file(file_ext, file_content_type):
@ -533,7 +412,6 @@ 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 (
@ -587,8 +465,8 @@ class Loader:
params = self.kwargs.get('DOCLING_PARAMS', {})
if not isinstance(params, dict):
try:
params = JSONCodec.loads(params)
except JSONCodec.JSONDecodeError:
params = json.loads(params)
except json.JSONDecodeError:
log.error('Invalid DOCLING_PARAMS format, expected JSON object')
params = {}
@ -633,6 +511,7 @@ class Loader:
mineru_timeout = int(mineru_timeout)
except ValueError:
mineru_timeout = 300
loader = MinerULoader(
file_path=file_path,
api_mode=self.kwargs.get('MINERU_API_MODE', 'local'),
@ -640,7 +519,6 @@ class Loader:
api_key=self.kwargs.get('MINERU_API_KEY', ''),
params=self.kwargs.get('MINERU_PARAMS', {}),
timeout=mineru_timeout,
max_markdown_bytes=MINERU_MAX_MARKDOWN_BYTES,
)
elif (
self.engine == 'mistral_ocr'
@ -651,15 +529,8 @@ class Loader:
base_url=self.kwargs.get('MISTRAL_OCR_API_BASE_URL'),
api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'),
file_path=file_path,
use_base64=self.kwargs.get('MISTRAL_OCR_USE_BASE64', False),
user=self.user,
)
elif (
self.engine == 'paddleocr_vl'
and self.kwargs.get('PADDLEOCR_VL_BASE_URL')
and self.kwargs.get('PADDLEOCR_VL_TOKEN')
and file_ext in PADDLEOCR_VL_SUPPORTED_EXTENSIONS
):
elif self.engine == 'paddleocr_vl' and self.kwargs.get('PADDLEOCR_VL_TOKEN') != '':
loader = PaddleOCRVLLoader(
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
@ -673,11 +544,7 @@ class Loader:
mode=self.kwargs.get('PDF_LOADER_MODE', 'page'),
)
elif file_ext == 'csv':
loader = CSVLoaderWithSummary(
file_path,
filename,
self._detect_text_encoding(file_path),
)
loader = CSVLoader(file_path, encoding=self._detect_text_encoding(file_path))
elif file_ext == 'rst':
try:
from langchain_community.document_loaders import UnstructuredRSTLoader
@ -762,18 +629,7 @@ class Loader:
)
loader = PptxLoader(file_path)
elif file_ext == 'msg':
try:
from langchain_community.document_loaders import (
UnstructuredEmailLoader,
)
# unstructured parses .msg via python-oxmsg; avoids extract_msg's beautifulsoup4<4.14 conflict
loader = UnstructuredEmailLoader(file_path, process_attachments=False)
except ImportError:
raise ValueError(
"Processing .msg files requires the 'unstructured' package. "
'Install it with: pip install unstructured'
)
loader = OutlookMessageLoader(file_path)
elif file_ext == 'odt':
try:
from langchain_community.document_loaders import UnstructuredODTLoader

View file

@ -1,109 +0,0 @@
import logging
import time
from collections.abc import Iterator
from typing import Any
from urllib.parse import urlparse
import requests
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
log = logging.getLogger(__name__)
MICROSOFT_BROWSE_RETRY_STATUS_CODES = {202, 429, 500, 502, 503, 504}
MICROSOFT_BROWSE_MAX_RETRIES = 2
class MicrosoftWebIQLoader(BaseLoader):
def __init__(
self,
urls: str | list[str],
api_base_url: str,
api_key: str,
language: str = 'en',
verify_ssl: bool = True,
timeout: Any = None,
continue_on_failure: bool = True,
) -> None:
self.urls = urls if isinstance(urls, list) else [urls]
self.api_base_url = api_base_url.rstrip('/')
self.api_key = api_key
self.language = language
self.verify_ssl = verify_ssl
self.timeout = timeout
self.continue_on_failure = continue_on_failure
def lazy_load(self) -> Iterator[Document]:
for url in self.urls:
try:
doc = self._browse_url(url)
if doc is not None:
yield doc
except Exception as e:
if self.continue_on_failure:
log.warning(f'Error browsing {url} with Microsoft Web IQ: {e}')
else:
raise e
def _browse_url(self, url: str) -> Document | None:
headers = {
'host': urlparse(self.api_base_url).netloc or 'api.microsoft.ai',
'x-apikey': self.api_key,
'content-type': 'application/json',
}
payload = {
'url': url,
'contentFormat': 'markdown',
'liveCrawl': 'fallback',
'renderDynamicPages': True,
'language': self.language,
}
try:
request_timeout = float(self.timeout)
except (TypeError, ValueError):
request_timeout = 60
request_timeout = request_timeout if request_timeout > 0 else 60
data: dict[str, Any] = {}
for attempt in range(MICROSOFT_BROWSE_MAX_RETRIES + 1):
response = requests.post(
f'{self.api_base_url}/browse',
json=payload,
headers=headers,
timeout=request_timeout,
verify=self.verify_ssl,
)
if response.status_code in MICROSOFT_BROWSE_RETRY_STATUS_CODES and attempt < MICROSOFT_BROWSE_MAX_RETRIES:
try:
body = response.json()
except Exception:
body = {}
retry_after = body.get('retryAfter') if isinstance(body, dict) else None
retry_after = retry_after or response.headers.get('Retry-After')
try:
delay = min(10.0, max(0.0, float(str(retry_after).rstrip('s'))))
except (TypeError, ValueError):
delay = min(8.0, float(2**attempt))
log.warning(
'Microsoft Browse %s returned HTTP %s; retrying in %.1fs',
url,
response.status_code,
delay,
)
time.sleep(delay)
continue
response.raise_for_status()
data = response.json()
break
content = data.get('content') or ''
if not isinstance(content, str) or not content.strip():
return None
metadata = {'source': data.get('url') or url}
if data.get('title'):
metadata['title'] = data['title']
return Document(page_content=content, metadata=metadata)

View file

@ -28,22 +28,20 @@ class MinerULoader:
api_key: str = '',
params: dict = None,
timeout: Optional[int] = 300,
max_markdown_bytes: Optional[int] = None,
):
self.file_path = file_path
self.api_mode = api_mode.lower()
self.api_url = api_url.rstrip('/')
self.api_key = api_key
self.timeout = timeout
self.max_markdown_bytes = max_markdown_bytes
# Parse params dict with defaults
self.params = params or {}
self.enable_ocr = self.params.get('enable_ocr', False)
self.enable_formula = self.params.get('enable_formula', True)
self.enable_table = self.params.get('enable_table', True)
self.language = self.params.get('language', 'en')
self.model_version = self.params.get('model_version', 'pipeline')
self.enable_ocr = params.get('enable_ocr', False)
self.enable_formula = params.get('enable_formula', True)
self.enable_table = params.get('enable_table', True)
self.language = params.get('language', 'en')
self.model_version = params.get('model_version', 'pipeline')
self.page_ranges = self.params.pop('page_ranges', '')
@ -74,7 +72,7 @@ class MinerULoader:
Load document using Local API (synchronous).
Posts file to /file_parse endpoint and gets immediate response.
"""
log.info('Using MinerU Local API at %s', self.api_url)
log.info(f'Using MinerU Local API at {self.api_url}')
filename = os.path.basename(self.file_path)
@ -97,8 +95,8 @@ class MinerULoader:
with open(self.file_path, 'rb') as f:
files = {'files': (filename, f, 'application/octet-stream')}
log.info('Sending file to MinerU Local API: %s', filename)
log.debug('Local API parameters: %s', form_data)
log.info(f'Sending file to MinerU Local API: {filename}')
log.debug(f'Local API parameters: {form_data}')
response = requests.post(
f'{self.api_url}/file_parse',
@ -163,7 +161,7 @@ class MinerULoader:
detail='MinerU returned empty markdown content',
)
log.info('Successfully parsed document with MinerU Local API: %s', filename)
log.info(f'Successfully parsed document with MinerU Local API: {filename}')
# Create metadata
metadata = {
@ -180,7 +178,7 @@ class MinerULoader:
Load document using Cloud API (asynchronous).
Uses batch upload endpoint to avoid need for public file URLs.
"""
log.info('Using MinerU Cloud API at %s', self.api_url)
log.info(f'Using MinerU Cloud API at {self.api_url}')
filename = os.path.basename(self.file_path)
@ -196,7 +194,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('Successfully parsed document with MinerU Cloud API: %s', filename)
log.info(f'Successfully parsed document with MinerU Cloud API: {filename}')
# Create metadata
metadata = {
@ -232,8 +230,8 @@ class MinerULoader:
if self.page_ranges:
request_body['files'][0]['page_ranges'] = self.page_ranges
log.info('Requesting upload URL for: %s', filename)
log.debug('Cloud API request body: %s', request_body)
log.info(f'Requesting upload URL for: {filename}')
log.debug(f'Cloud API request body: {request_body}')
try:
response = requests.post(
@ -284,7 +282,7 @@ class MinerULoader:
)
upload_url = file_urls[0]
log.info('Received upload URL for batch: %s', batch_id)
log.info(f'Received upload URL for batch: {batch_id}')
return batch_id, upload_url
@ -334,7 +332,7 @@ class MinerULoader:
max_iterations = 300 # 10 minutes max (2 seconds per iteration)
poll_interval = 2 # seconds
log.info('Polling batch status: %s', batch_id)
log.info(f'Polling batch status: {batch_id}')
for iteration in range(max_iterations):
try:
@ -393,7 +391,7 @@ class MinerULoader:
state = file_result.get('state')
if state == 'done':
log.info('Processing complete for %s', filename)
log.info(f'Processing complete for {filename}')
return file_result
elif state == 'failed':
error_msg = file_result.get('err_msg', 'Unknown error')
@ -404,7 +402,7 @@ class MinerULoader:
elif state in ['waiting-file', 'pending', 'running', 'converting']:
# Still processing
if iteration % 10 == 0: # Log every 20 seconds
log.info('Processing status: %s (iteration %s/%s)', state, iteration + 1, max_iterations)
log.info(f'Processing status: {state} (iteration {iteration + 1}/{max_iterations})')
time.sleep(poll_interval)
else:
log.warning(f'Unknown state: {state}')
@ -421,7 +419,7 @@ class MinerULoader:
Download ZIP file from CDN and extract markdown content.
Returns the markdown content as a string.
"""
log.info('Downloading results from: %s', zip_url)
log.info(f'Downloading results from: {zip_url}')
try:
response = requests.get(zip_url, timeout=60)
@ -437,77 +435,67 @@ class MinerULoader:
detail=f'Error downloading results: {str(e)}',
)
# Save ZIP to temporary file before reading.
tmp_zip_path = None
markdown_content = None
# Save ZIP to temporary file and extract
try:
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_zip:
tmp_zip.write(response.content)
tmp_zip_path = tmp_zip.name
with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref:
members = zip_ref.infolist()
all_files = [member.filename for member in members]
md_members = [member for member in members if member.filename.endswith('.md')]
read_errors = []
with tempfile.TemporaryDirectory() as tmp_dir:
# Extract ZIP
with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref:
zip_ref.extractall(tmp_dir)
for member in md_members:
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:
content = f.read()
else:
content = f.read(self.max_markdown_bytes + 1)
if len(content) > self.max_markdown_bytes:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail=f'Markdown file in results ZIP is too large: {member.filename}',
)
markdown_content = content.decode('utf-8')
except UnicodeDecodeError as e:
read_errors.append(f'{member.filename}: {e}')
log.warning(f'Failed to decode {member.filename}: {e}')
continue
except HTTPException:
raise
except Exception as e:
read_errors.append(f'{member.filename}: {e}')
log.warning(f'Failed to read {member.filename}: {e}')
continue
# Find markdown file - search recursively for any .md file
markdown_content = None
found_md_path = None
# First, list all files in the ZIP for debugging
all_files = []
for root, dirs, files in os.walk(tmp_dir):
for file in files:
full_path = os.path.join(root, file)
all_files.append(full_path)
# Look for any .md file
if file.endswith('.md'):
found_md_path = full_path
log.info(f'Found markdown file at: {full_path}')
try:
with open(full_path, 'r', encoding='utf-8') as f:
markdown_content = f.read()
if markdown_content: # Use the first non-empty markdown file
break
except Exception as e:
log.warning(f'Failed to read {full_path}: {e}')
if markdown_content:
break
if markdown_content is None:
log.error(f'Available files in ZIP: {all_files}')
if read_errors:
error_msg = f"Found .md files but couldn't read them: {read_errors}"
# Try to provide more helpful error message
md_files = [f for f in all_files if f.endswith('.md')]
if md_files:
error_msg = f"Found .md files but couldn't read them: {md_files}"
else:
error_msg = f'No .md files found in ZIP. Available files: {all_files}'
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail=error_msg,
)
# Clean up temporary ZIP file
os.unlink(tmp_zip_path)
except zipfile.BadZipFile as e:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail=f'Invalid ZIP file received: {e}',
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Error extracting ZIP: {str(e)}',
)
finally:
if tmp_zip_path:
try:
os.unlink(tmp_zip_path)
except FileNotFoundError:
pass
except Exception as e:
log.warning(f'Failed to remove temporary ZIP file {tmp_zip_path}: {e}')
if not markdown_content:
raise HTTPException(
@ -515,5 +503,5 @@ class MinerULoader:
detail='Extracted markdown content is empty',
)
log.info('Successfully extracted markdown content (%s characters)', len(markdown_content))
log.info(f'Successfully extracted markdown content ({len(markdown_content)} characters)')
return markdown_content

View file

@ -1,14 +1,15 @@
import base64
import asyncio
import logging
import os
import sys
import time
from typing import Any, Dict, List, Optional
from contextlib import asynccontextmanager
from typing import Any, Dict, List
import aiohttp
import requests
from langchain_core.documents import Document
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL
from open_webui.utils.headers import include_user_info_headers
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -16,12 +17,15 @@ log = logging.getLogger(__name__)
class MistralLoader:
"""
Enhanced Mistral OCR loader.
Enhanced Mistral OCR loader with both sync and async support.
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
"""
@ -33,8 +37,6 @@ class MistralLoader:
timeout: int = 300, # 5 minutes default
max_retries: int = 3,
enable_debug_logging: bool = False,
use_base64: bool = False,
user: Optional[Any] = None,
):
"""
Initializes the loader with enhanced features.
@ -45,9 +47,6 @@ class MistralLoader:
timeout: Request timeout in seconds.
max_retries: Maximum number of retry attempts.
enable_debug_logging: Enable detailed debug logs.
use_base64: Send the document as a data URL instead of uploading it first.
user: The requesting user, forwarded to Mistral via user-info headers
when ENABLE_FORWARD_USER_INFO_HEADERS is enabled.
"""
if not api_key:
raise ValueError('API key cannot be empty.')
@ -57,10 +56,9 @@ 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
self.user = user
# PERFORMANCE OPTIMIZATION: Differentiated timeouts for different operations
# This prevents long-running OCR operations from affecting quick operations
@ -80,8 +78,6 @@ class MistralLoader:
'Authorization': f'Bearer {self.api_key}',
'User-Agent': 'OpenWebUI-MistralLoader/2.0', # Helps API provider track usage
}
if self.user is not None and ENABLE_FORWARD_USER_INFO_HEADERS:
self.headers = include_user_info_headers(self.headers, self.user)
def _debug_log(self, message: str, *args) -> None:
"""
@ -111,6 +107,32 @@ 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.
@ -140,6 +162,10 @@ 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):
@ -166,11 +192,32 @@ 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.
Uploads the file to Mistral for OCR processing (sync version).
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.
@ -203,15 +250,57 @@ class MistralLoader:
file_id = response_data.get('id')
if not file_id:
raise ValueError('File ID not found in upload response.')
log.info('File uploaded successfully. File ID: %s', file_id)
log.info(f'File uploaded successfully. File ID: {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():
# Create multipart writer for streaming upload
writer = aiohttp.MultipartWriter('form-data')
# Add purpose field
purpose_part = writer.append('ocr')
purpose_part.set_content_disposition('form-data', name='purpose')
# Add file part with streaming
file_part = writer.append_payload(
aiohttp.streams.FilePayload(
self.file_path,
filename=self.file_name,
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."""
log.info('Getting signed URL for file ID: %s', file_id)
"""Retrieves a temporary signed URL for the uploaded file (sync version)."""
log.info(f'Getting signed URL for file ID: {file_id}')
url = f'{self.base_url}/files/{file_id}/url'
params = {'expiry': 1}
signed_url_headers = {**self.headers, 'Accept': 'application/json'}
@ -231,8 +320,35 @@ 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."""
"""Sends the signed URL to the OCR endpoint for processing (sync version)."""
log.info('Processing OCR via Mistral API')
url = f'{self.base_url}/ocr'
ocr_headers = {
@ -262,24 +378,108 @@ class MistralLoader:
log.error(f'Failed during OCR processing: {e}')
raise
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}'
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 _delete_file(self, file_id: str) -> None:
"""Deletes the file from Mistral storage."""
log.info('Deleting uploaded file ID: %s', file_id)
"""Deletes the file from Mistral storage (sync version)."""
log.info(f'Deleting uploaded file ID: {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('File deleted successfully: %s', delete_response)
log.info(f'File deleted successfully: {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')
@ -304,7 +504,7 @@ class MistralLoader:
if page_content is None or page_index is None:
skipped_pages += 1
self._debug_log(
"Skipping page due to missing 'markdown' or 'index'. Data keys: %s", list(page_data.keys())
f"Skipping page due to missing 'markdown' or 'index'. Data keys: {list(page_data.keys())}"
)
continue
@ -316,7 +516,7 @@ class MistralLoader:
if not cleaned_content:
skipped_pages += 1
self._debug_log('Skipping empty page %s', page_index)
self._debug_log(f'Skipping empty page {page_index}')
continue
# Create document with optimized metadata
@ -336,7 +536,7 @@ class MistralLoader:
)
if skipped_pages > 0:
log.info('Processed %s pages, skipped %s empty/invalid pages', len(documents), skipped_pages)
log.info(f'Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages')
if not documents:
# Case where pages existed but none had valid markdown/index
@ -357,6 +557,7 @@ 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.
@ -365,12 +566,6 @@ class MistralLoader:
start_time = time.time()
try:
if self.use_base64:
documents = self._process_results(self._process_ocr(self._get_file_data_url()))
total_time = time.time() - start_time
log.info('Sync OCR workflow completed in %.2fs, produced %s documents', total_time, len(documents))
return documents
# 1. Upload file
file_id = self._upload_file()
@ -384,7 +579,7 @@ class MistralLoader:
documents = self._process_results(ocr_response)
total_time = time.time() - start_time
log.info('Sync OCR workflow completed in %.2fs, produced %s documents', total_time, len(documents))
log.info(f'Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents')
return documents
@ -409,3 +604,118 @@ 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:
# 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

View file

@ -11,9 +11,6 @@ from open_webui.env import GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
PADDLEOCR_VL_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
PADDLEOCR_VL_SUPPORTED_EXTENSIONS = ['pdf'] + PADDLEOCR_VL_IMAGE_EXTENSIONS
class PaddleOCRVLLoader:
"""Loader that uses PaddleOCR-vl API to extract text from PDF/images."""
@ -35,7 +32,7 @@ class PaddleOCRVLLoader:
self.file_name = os.path.basename(file_path)
def load(self) -> List[Document]:
log.info('Processing with PaddleOCR-vl: %s', self.file_path)
log.info(f'Processing with PaddleOCR-vl: {self.file_path}')
try:
with open(self.file_path, 'rb') as file:
@ -49,7 +46,8 @@ class PaddleOCRVLLoader:
# Detect fileType based on file extension
ext = self.file_path.lower().split('.')[-1]
file_type = 1 if ext in PADDLEOCR_VL_IMAGE_EXTENSIONS else 0
image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
file_type = 1 if ext in image_extensions else 0
payload = {
'file': file_data,
@ -96,7 +94,7 @@ class PaddleOCRVLLoader:
)
if skipped_pages > 0:
log.info('PaddleOCR-vl: Processed %s pages, skipped %s empty pages.', len(documents), skipped_pages)
log.info(f'PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.')
if not documents:
log.warning('No valid text content found by PaddleOCR-vl.')

View file

@ -4,7 +4,6 @@ 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__)
@ -49,7 +48,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 = f'{TAVILY_API_BASE_URL}/extract'
self.api_url = 'https://api.tavily.com/extract'
def lazy_load(self) -> Iterator[Document]:
"""Extract and yield documents from the URLs using Tavily Extract API."""

View file

@ -18,32 +18,6 @@ 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)
@ -116,7 +90,7 @@ class YoutubeLoader:
if self.proxy_url:
youtube_proxies = GenericProxyConfig(http_url=self.proxy_url, https_url=self.proxy_url)
log.debug('Using proxy URL: %s...', self.proxy_url[:14])
log.debug(f'Using proxy URL: {self.proxy_url[:14]}...')
else:
youtube_proxies = None
@ -124,31 +98,31 @@ class YoutubeLoader:
try:
transcript_list = transcript_api.list(self.video_id)
except Exception as e:
log.warning('Loading YouTube transcript failed: %s', e)
raise YoutubeTranscriptError(_transcript_error_message(e, self.video_id)) from e
log.warning(f'Loading YouTube transcript failed: {e}')
return []
# 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("Found generated transcript for language '%s'", lang)
log.debug(f"Found generated transcript for language '{lang}'")
try:
transcript = transcript_list.find_manually_created_transcript([lang])
log.debug("Found manual transcript for language '%s'", lang)
log.debug(f"Found manual transcript for language '{lang}'")
except NoTranscriptFound:
log.debug("No manual transcript found for language '%s', using generated", lang)
log.debug(f"No manual transcript found for language '{lang}', using generated")
pass
log.debug("Found transcript for language '%s'", lang)
log.debug(f"Found transcript for language '{lang}'")
try:
transcript_pieces: List[Dict[str, Any]] = transcript.fetch()
except ParseError:
log.debug("Empty or invalid transcript for language '%s'", lang)
log.debug(f"Empty or invalid transcript for language '{lang}'")
continue
if not transcript_pieces:
log.debug("Empty transcript for language '%s'", lang)
log.debug(f"Empty transcript for language '{lang}'")
continue
transcript_text = ' '.join(
@ -161,20 +135,18 @@ class YoutubeLoader:
)
return [Document(page_content=transcript_text, metadata=self._metadata)]
except NoTranscriptFound:
log.debug("No transcript found for language '%s'", lang)
log.debug(f"No transcript found for language '{lang}'")
continue
except Exception as e:
log.info("Error finding transcript for language '%s'", lang)
raise YoutubeTranscriptError(_transcript_error_message(e, self.video_id)) from e
log.info(f"Error finding transcript for language '{lang}'")
raise 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 YoutubeTranscriptError(
f'No transcript found for the YouTube video {self.video_id} in these languages: {languages_tried}.'
)
raise NoTranscriptFound(self.video_id, self.language, list(transcript_list))
async def aload(self) -> Generator[Document, None, None]:
"""Asynchronously load YouTube transcripts into `Document` objects."""

View file

@ -12,7 +12,7 @@ log = logging.getLogger(__name__)
class ColBERT(BaseReranker):
def __init__(self, name, **kwargs) -> None:
log.info('ColBERT: Loading model %s', name)
log.info('ColBERT: Loading model', name)
self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
DOCKER = kwargs.get('env') == 'docker'

View file

@ -35,8 +35,8 @@ class ExternalReranker(BaseReranker):
}
try:
log.info('ExternalReranker:predict:model %s', self.model)
log.info('ExternalReranker:predict:query %s', query)
log.info(f'ExternalReranker:predict:model {self.model}')
log.info(f'ExternalReranker:predict:query {query}')
headers = {
'Content-Type': 'application/json',

View file

@ -6,6 +6,7 @@ import logging
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Awaitable, Optional, Union
from urllib.parse import quote
@ -24,7 +25,6 @@ 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,
@ -37,21 +37,17 @@ from open_webui.env import (
from open_webui.models.access_grants import AccessGrants
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.models.folders import Folders
from open_webui.models.knowledge import Knowledges
from open_webui.models.notes import Notes
from open_webui.models.config import Config
from open_webui.models.users import UserModel
from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.retrieval.external import retrieve_external_knowledge
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.main import GetResult, SearchResult
from open_webui.retrieval.vector.main import GetResult
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 get_json_bearer_headers, include_user_info_headers
from open_webui.utils.misc import get_content_from_message, get_message_list
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.misc import get_message_list
log = logging.getLogger(__name__)
@ -67,102 +63,65 @@ def is_youtube_url(url: str) -> bool:
return re.match(youtube_regex, url) is not None
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',
'web_loader_concurrent_requests': 'web.loader.concurrent_requests',
'web_search_trust_env': 'web.search.trust_env',
'web_loader_engine': 'web.loader.engine',
'web_loader_timeout': 'web.loader.timeout',
'playwright_ws_url': 'web.loader.playwright_ws_url',
'playwright_timeout': 'web.loader.playwright_timeout',
'firecrawl_api_key': 'web.loader.firecrawl_api_key',
'firecrawl_api_url': 'web.loader.firecrawl_api_url',
'firecrawl_timeout': 'web.loader.firecrawl_timeout',
'tavily_api_key': 'web.search.tavily_api_key',
'tavily_extract_depth': 'web.search.tavily_extract_depth',
'microsoft_web_iq_api_base_url': 'web.search.microsoft_web_iq_api_base_url',
'microsoft_web_iq_api_key': 'web.search.microsoft_web_iq_api_key',
'microsoft_web_iq_language': 'web.search.microsoft_web_iq_language',
'external_web_loader_url': 'web.loader.external_web_loader_url',
'external_web_loader_api_key': 'web.loader.external_web_loader_api_key',
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key',
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
'DATALAB_MARKER_ADDITIONAL_CONFIG': 'rag.datalab_marker_additional_config',
'DATALAB_MARKER_SKIP_CACHE': 'rag.datalab_marker_skip_cache',
'DATALAB_MARKER_FORCE_OCR': 'rag.datalab_marker_force_ocr',
'DATALAB_MARKER_PAGINATE': 'rag.datalab_marker_paginate',
'DATALAB_MARKER_STRIP_EXISTING_OCR': 'rag.datalab_marker_strip_existing_ocr',
'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': 'rag.datalab_marker_disable_image_extraction',
'DATALAB_MARKER_FORMAT_LINES': 'rag.datalab_marker_format_lines',
'DATALAB_MARKER_USE_LLM': 'rag.datalab_marker_use_llm',
'DATALAB_MARKER_OUTPUT_FORMAT': 'rag.datalab_marker_output_format',
'EXTERNAL_DOCUMENT_LOADER_URL': 'rag.external_document_loader_url',
'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',
'PDF_EXTRACT_IMAGES': 'rag.pdf_extract_images',
'PDF_LOADER_MODE': 'rag.pdf_loader_mode',
'DOCUMENT_INTELLIGENCE_ENDPOINT': 'rag.document_intelligence_endpoint',
'DOCUMENT_INTELLIGENCE_KEY': 'rag.document_intelligence_key',
'DOCUMENT_INTELLIGENCE_MODEL': 'rag.document_intelligence_model',
'MISTRAL_OCR_API_BASE_URL': 'rag.mistral_ocr_api_base_url',
'MISTRAL_OCR_API_KEY': 'rag.mistral_ocr_api_key',
'MISTRAL_OCR_USE_BASE64': 'rag.mistral_ocr_use_base64',
'PADDLEOCR_VL_BASE_URL': 'rag.paddleocr_vl_base_url',
'PADDLEOCR_VL_TOKEN': 'rag.paddleocr_vl_token',
'MINERU_API_MODE': 'rag.mineru_api_mode',
'MINERU_API_URL': 'rag.mineru_api_url',
'MINERU_API_KEY': 'rag.mineru_api_key',
'MINERU_API_TIMEOUT': 'rag.mineru_api_timeout',
'MINERU_PARAMS': 'rag.mineru_params',
'MINERU_FILE_EXTENSIONS': 'rag.mineru_file_extensions',
}
async def get_loader_config():
values = await Config.get_many(*LOADER_CONFIG_KEYS.values())
return {name: values.get(key) for name, key in LOADER_CONFIG_KEYS.items()}
def get_loader(request, url: str, config: dict):
def get_loader(request, url: str):
if is_youtube_url(url):
return YoutubeLoader(
url,
language=config.get('youtube_language'),
proxy_url=config.get('youtube_proxy_url'),
language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
)
else:
return get_web_loader(
url,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
)
return get_web_loader(
url,
verify_ssl=config.get('web_loader_ssl_verification'),
requests_per_second=config.get('web_loader_concurrent_requests'),
trust_env=config.get('web_search_trust_env'),
loader_config=config,
)
def build_loader_from_config(request, config: dict):
def build_loader_from_config(request):
"""Build a Loader instance with the admin's configured extraction engine settings."""
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')
config = request.app.state.config
return Loader(
engine=loader_config['CONTENT_EXTRACTION_ENGINE'],
**{key: value for key, value in loader_config.items() if key != 'CONTENT_EXTRACTION_ENGINE'},
engine=config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_API_BASE_URL=config.DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG=config.DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE=config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=config.EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY=config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL=config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=config.DOCLING_SERVER_URL,
DOCLING_API_KEY=config.DOCLING_API_KEY,
DOCLING_PARAMS=config.DOCLING_PARAMS,
PDF_EXTRACT_IMAGES=config.PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE=config.PDF_LOADER_MODE,
DOCUMENT_INTELLIGENCE_ENDPOINT=config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=config.DOCUMENT_INTELLIGENCE_KEY,
DOCUMENT_INTELLIGENCE_MODEL=config.DOCUMENT_INTELLIGENCE_MODEL,
MISTRAL_OCR_API_BASE_URL=config.MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY=config.MISTRAL_OCR_API_KEY,
PADDLEOCR_VL_BASE_URL=config.PADDLEOCR_VL_BASE_URL,
PADDLEOCR_VL_TOKEN=config.PADDLEOCR_VL_TOKEN,
MINERU_API_MODE=config.MINERU_API_MODE,
MINERU_API_URL=config.MINERU_API_URL,
MINERU_API_KEY=config.MINERU_API_KEY,
MINERU_API_TIMEOUT=config.MINERU_API_TIMEOUT,
MINERU_PARAMS=config.MINERU_PARAMS,
MINERU_FILE_EXTENSIONS=config.MINERU_FILE_EXTENSIONS,
)
def _extract_text_from_binary_response(
request, response: requests.Response, url: str, loader_config: dict
) -> tuple[str, list]:
def _extract_text_from_binary_response(request, response: requests.Response, url: str) -> tuple[str, list]:
"""Download response body to a temp file and extract text using the Loader pipeline."""
import mimetypes
import tempfile
@ -186,21 +145,12 @@ def _extract_text_from_binary_response(
suffix = '.' + filename.split('.')[-1].lower() if '.' in filename else ''
max_size = loader_config.get('file_max_size')
max_bytes = int(max_size) * 1024 * 1024 if max_size else 0
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(response.content)
tmp_path = tmp.name
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)
loader = build_loader_from_config(request)
docs = loader.load(filename, content_type, tmp_path)
for doc in docs:
doc.metadata['source'] = url
@ -210,37 +160,18 @@ 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 ct in TEXT_APPLICATION_CONTENT_TYPES:
if any(t in ct for t in ['xml', 'json', 'javascript']):
return True
return ct.endswith(('+xml', '+json'))
return not ct # empty / missing → assume HTML
async def get_content_from_url(request, url: str) -> str:
loader_config = await get_loader_config()
# The rest of this function performs synchronous, blocking work: an SSRF-guarded
# `requests` probe and a synchronous document loader (`loader.load()`). Run it in a
# worker thread so the event loop stays free while waiting on network/parsing.
return await asyncio.to_thread(_get_content_from_url_sync, request, url, loader_config)
def _get_content_from_url_sync(request, url: str, loader_config):
from open_webui.retrieval.web.utils import validate_url, get_ssrf_safe_requests_session
def get_content_from_url(request, url: str) -> str:
from open_webui.retrieval.web.utils import validate_url
# Validate URL before making any request (blocks private IPs, non-HTTP, filter list)
validate_url(url)
@ -252,7 +183,7 @@ def _get_content_from_url_sync(request, url: str, loader_config):
# when allow_redirects=False, causing the binary-content path to run
# and produce empty docs → HTTP 400.
if is_youtube_url(url):
loader = get_loader(request, url, loader_config)
loader = get_loader(request, url)
docs = loader.load()
content = ' '.join([doc.page_content for doc in docs])
return content, docs
@ -263,9 +194,7 @@ def _get_content_from_url_sync(request, url: str, loader_config):
# re-validation would let an attacker reach private IPs (RFC1918, loopback,
# 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 = get_ssrf_safe_requests_session()
response = session.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS)
response = requests.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
except Exception:
@ -276,14 +205,14 @@ def _get_content_from_url_sync(request, url: str, loader_config):
if response is None or _is_text_content_type(content_type):
if response is not None:
response.close()
loader = get_loader(request, url, loader_config)
loader = get_loader(request, url)
docs = loader.load()
content = ' '.join([doc.page_content for doc in docs])
return content, docs
# Binary content (PDF, DOCX, XLSX, PPTX, etc.) — download and extract
try:
return _extract_text_from_binary_response(request, response, url, loader_config)
return _extract_text_from_binary_response(request, response, url)
finally:
response.close()
@ -326,12 +255,26 @@ class VectorSearchRetriever(BaseRetriever):
limit=self.top_k,
)
return _search_result_to_documents(result)
ids = result.ids[0]
metadatas = result.metadatas[0]
documents = result.documents[0]
results = []
for idx in range(len(ids)):
metadata = metadatas[idx]
metadata[CHUNK_HASH_KEY] = _content_hash(documents[idx])
results.append(
Document(
metadata=metadata,
page_content=documents[idx],
)
)
return results
def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None):
try:
log.debug('query_doc:doc %s', collection_name)
log.debug(f'query_doc:doc {collection_name}')
result = VECTOR_DB_CLIENT.search(
collection_name=collection_name,
vectors=[query_embedding],
@ -339,7 +282,7 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user:
)
if result:
log.info('query_doc:result %s %s', result.ids, result.metadatas)
log.info(f'query_doc:result {result.ids} {result.metadatas}')
return result
except Exception as e:
@ -349,11 +292,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('get_doc:doc %s', collection_name)
log.debug(f'get_doc:doc {collection_name}')
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
if result:
log.info('query_doc:result %s %s', result.ids, result.metadatas)
log.info(f'query_doc:result {result.ids} {result.metadatas}')
return result
except Exception as e:
@ -395,96 +338,9 @@ def get_enriched_texts(collection_result: GetResult) -> list[str]:
return enriched_texts
def _search_result_to_documents(result: SearchResult | None) -> list[Document]:
ids = result.ids[0] if result and result.ids else []
metadatas = result.metadatas[0] if result and result.metadatas else []
documents = result.documents[0] if result and result.documents else []
distances = result.distances[0] if result and result.distances else []
docs = []
for idx in range(len(ids)):
document = documents[idx]
metadata = dict(metadatas[idx] or {})
metadata[CHUNK_HASH_KEY] = _content_hash(document)
if idx < len(distances):
metadata.setdefault('score', distances[idx])
docs.append(Document(metadata=metadata, page_content=document))
return docs
def _supports_native_hybrid_search() -> bool:
supports_hybrid_search = getattr(ASYNC_VECTOR_DB_CLIENT, 'supports_hybrid_search', None)
if supports_hybrid_search is not None:
return bool(supports_hybrid_search)
return callable(getattr(ASYNC_VECTOR_DB_CLIENT, 'hybrid_search', None))
async def query_doc_with_native_hybrid_search(
collection_name: str,
query: str,
embedding_function,
k: int,
reranking_function,
k_reranker: int,
r: float,
hybrid_bm25_weight: float,
) -> Optional[dict]:
try:
if not _supports_native_hybrid_search():
return None
query_vectors = []
if hybrid_bm25_weight < 1:
query_vectors = [await embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)]
result = await ASYNC_VECTOR_DB_CLIENT.hybrid_search(
collection_name=collection_name,
query=query,
vectors=query_vectors,
limit=k,
hybrid_bm25_weight=hybrid_bm25_weight,
)
if result is None:
return None
documents = _search_result_to_documents(result)
if not documents:
return {'distances': [[]], 'documents': [[]], 'metadatas': [[]]}
compressor = RerankCompressor(
embedding_function=embedding_function,
top_n=k_reranker,
reranking_function=reranking_function,
r_score=r,
)
compressed = await compressor.acompress_documents(documents, query)
distances = [d.metadata.get('score') for d in compressed]
documents = [d.page_content for d in compressed]
metadatas = [d.metadata for d in compressed]
if k < k_reranker:
sorted_items = sorted(zip(distances, documents, metadatas), key=lambda x: x[0], reverse=True)
sorted_items = sorted_items[:k]
if sorted_items:
distances, documents, metadatas = map(list, zip(*sorted_items))
else:
distances, documents, metadatas = [], [], []
return {
'distances': [distances],
'documents': [documents],
'metadatas': [metadatas],
}
except Exception as e:
log.debug('Native hybrid search failed for %s, falling back to legacy hybrid search: %s', collection_name, e)
return None
async def query_doc_with_hybrid_search(
collection_name: str,
collection_result: Optional[GetResult],
collection_result: GetResult,
query: str,
embedding_function,
k: int,
@ -493,26 +349,8 @@ async def query_doc_with_hybrid_search(
r: float,
hybrid_bm25_weight: float,
enable_enriched_texts: bool = False,
native_hybrid_search: bool = True,
) -> dict:
try:
if native_hybrid_search and not enable_enriched_texts:
native_result = await query_doc_with_native_hybrid_search(
collection_name=collection_name,
query=query,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
)
if native_result is not None:
return native_result
if collection_result is None:
collection_result = await ASYNC_VECTOR_DB_CLIENT.get(collection_name=collection_name)
# First check if collection_result has the required attributes
if (
not collection_result
@ -531,7 +369,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('query_doc_with_hybrid_search:doc %s', collection_name)
log.debug(f'query_doc_with_hybrid_search:doc {collection_name}')
original_texts = collection_result.documents[0]
bm25_metadatas = [
@ -606,7 +444,7 @@ async def query_doc_with_hybrid_search(
'metadatas': [metadatas],
}
log.info('query_doc_with_hybrid_search:result %s %s', result['metadatas'], result['distances'])
log.info('query_doc_with_hybrid_search:result ' + f'{result["metadatas"]} {result["distances"]}')
return result
except Exception as e:
log.exception(f'Error querying doc {collection_name} with hybrid search: {e}')
@ -652,9 +490,9 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
for distance, document, metadata in zip(distances, documents, metadatas):
if isinstance(document, str):
doc_hash = (metadata or {}).get(CHUNK_HASH_KEY) or _content_hash(document)
doc_hash = hashlib.sha256(document.encode()).hexdigest() # Compute a hash for uniqueness
if doc_hash not in combined:
if doc_hash not in combined.keys():
combined[doc_hash] = (distance, document, metadata)
continue # if doc is new, no further comparison is needed
@ -701,15 +539,8 @@ async def query_collection(
embedding_function,
k: int,
) -> dict:
config = await Config.get_many(
'rag.enable_hybrid_search',
'rag.top_k_reranker',
'rag.relevance_threshold',
'rag.hybrid_bm25_weight',
'rag.enable_hybrid_search_enriched_texts',
)
# When request is provided, try hybrid search + reranking if enabled
if request and config.get('rag.enable_hybrid_search'):
if request and request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
try:
reranking_function = (
(lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents))
@ -722,13 +553,13 @@ async def query_collection(
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=config.get('rag.top_k_reranker'),
r=config.get('rag.relevance_threshold'),
hybrid_bm25_weight=config.get('rag.hybrid_bm25_weight'),
enable_enriched_texts=config.get('rag.enable_hybrid_search_enriched_texts'),
k_reranker=request.app.state.config.TOP_K_RERANKER,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS,
)
except Exception as e:
log.debug('Hybrid search failed, falling back to vector search: %s', e)
log.debug(f'Hybrid search failed, falling back to vector search: {e}')
results = []
error = False
@ -757,15 +588,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('query_collection: processing %s queries across %s collections', len(queries), len(collection_names))
log.debug(f'query_collection: processing {len(queries)} queries across {len(collection_names)} collections')
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
]
)
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]
for result, err in task_results:
if err is not None:
@ -792,28 +623,6 @@ async def query_collection_with_hybrid_search(
) -> dict:
results = []
error = False
if not enable_enriched_texts:
async def process_native_query(collection_name, query):
result = await query_doc_with_native_hybrid_search(
collection_name=collection_name,
query=query,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
)
return result
native_task_results = await asyncio.gather(
*[process_native_query(collection_name, query) for collection_name in collection_names for query in queries]
)
if native_task_results and all(result is not None for result in native_task_results):
return merge_and_sort_query_results(native_task_results, k=k)
# Fetch every collection's contents once up front so the
# per-query/per-document loop below can reuse them. Each fetch
# offloads to a worker thread, so run them concurrently with
@ -833,7 +642,7 @@ async def query_collection_with_hybrid_search(
collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names)))
log.info('Starting hybrid search for %s queries in %s collections...', len(queries), len(collection_names))
log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...')
async def process_query(collection_name, query):
try:
@ -848,7 +657,6 @@ async def query_collection_with_hybrid_search(
r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
enable_enriched_texts=enable_enriched_texts,
native_hybrid_search=False,
)
return result, None
except Exception as e:
@ -887,12 +695,15 @@ def generate_openai_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug('generate_openai_batch_embeddings:model %s batch size: %s', model, len(texts))
log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {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 = get_json_bearer_headers(key)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
@ -917,12 +728,15 @@ async def agenerate_openai_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug('agenerate_openai_batch_embeddings:model %s batch size: %s', model, len(texts))
log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {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 = get_json_bearer_headers(key)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
@ -952,7 +766,7 @@ def generate_azure_openai_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug('generate_azure_openai_batch_embeddings:deployment %s batch size: %s', model, len(texts))
log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {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
@ -994,7 +808,7 @@ async def agenerate_azure_openai_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug('agenerate_azure_openai_batch_embeddings:deployment %s batch size: %s', model, len(texts))
log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {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
@ -1033,12 +847,15 @@ def generate_ollama_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug('generate_ollama_batch_embeddings:model %s batch size: %s', model, len(texts))
log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {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 = get_json_bearer_headers(key)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
@ -1066,12 +883,15 @@ async def agenerate_ollama_batch_embeddings(
prefix: str = None,
user: UserModel = None,
) -> list[list[float]]:
log.debug('agenerate_ollama_batch_embeddings:model %s batch size: %s', model, len(texts))
log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {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 = get_json_bearer_headers(key)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
@ -1107,15 +927,15 @@ def get_embedding_function(
concurrent_requests=0,
) -> Awaitable:
if embedding_engine == '':
if embedding_function is None:
raise ValueError(
'No embedding model is loaded. Set RAG_EMBEDDING_MODEL to a valid '
'SentenceTransformer model name, or configure an external '
'RAG_EMBEDDING_ENGINE (ollama, openai, azure_openai).'
)
# Sentence transformers: CPU-bound sync operation
async def async_embedding_function(query, prefix=None, user=None):
# Deferred so a missing local model degrades RAG instead of crashing boot.
if embedding_function is None:
raise ValueError(
'No embedding model is loaded. Set RAG_EMBEDDING_MODEL to a valid '
'SentenceTransformer model name, or configure an external '
'RAG_EMBEDDING_ENGINE (ollama, openai, azure_openai).'
)
return await asyncio.to_thread(
(
lambda query, prefix=None: embedding_function.encode(
@ -1147,7 +967,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('generate_multiple_async: Processing %s batches in parallel', len(batches))
log.debug(f'generate_multiple_async: Processing {len(batches)} batches in parallel')
# Use semaphore to limit concurrent embedding API requests
# 0 = unlimited (no semaphore)
if concurrent_requests:
@ -1162,7 +982,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('generate_multiple_async: Processing %s batches sequentially', len(batches))
log.debug(f'generate_multiple_async: Processing {len(batches)} batches sequentially')
batch_results = []
for batch in batches:
batch_results.append(await embedding_function(batch, prefix=prefix, user=user))
@ -1175,9 +995,7 @@ def get_embedding_function(
embeddings.extend(batch_embeddings)
log.debug(
'generate_multiple_async: Generated %s embeddings from %s parallel batches',
len(embeddings),
len(batches),
f'generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches'
)
return embeddings
else:
@ -1277,7 +1095,7 @@ async def filter_accessible_collections(
- any name with characters outside [A-Za-z0-9_-] rejected
- file-* validated via has_access_to_file
- user-memory-* must match user's own memory collection
- web-search-* ephemeral per-query collections, owner-bound to web-search-{user.id}-*
- web-search-* ephemeral per-query collections, always allowed
- knowledge-bases always denied (system meta-collection)
- everything else if the name matches a knowledge base, validated
via Knowledges.check_access_by_user_id; if no
@ -1312,10 +1130,10 @@ async def filter_accessible_collections(
if name == f'user-memory-{user.id}':
validated.add(name)
elif name.startswith('web-search-'):
# Ephemeral per-query collections, owner-bound: process_web_search mints
# them as web-search-{user.id}-<hash>, so only the creator may read/write.
if name.startswith(f'web-search-{user.id}-'):
validated.add(name)
# Ephemeral collections created by process_web_search — safe
# to allow because they contain only transient web-search
# results scoped to the requesting user's session.
validated.add(name)
else:
# May be a knowledge-base ID or a legacy/ephemeral collection.
# If it IS a KB, enforce access control. If no such KB
@ -1345,28 +1163,10 @@ async def get_sources_from_items(
full_context=False,
user: UserModel | None = None,
):
log.debug('items: %s %s %s %s %s', items, queries, embedding_function, reranking_function, full_context)
log.debug(f'items: {items} {queries} {embedding_function} {reranking_function} {full_context}')
bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval')
extracted_collections = []
query_results = []
folder_items = set()
expanded_folders = set()
items = list(items)
for item in items:
if item.get('type') != 'folder' or not user:
continue
folder_id = item.get('id')
if not folder_id or folder_id in expanded_folders:
continue
expanded_folders.add(folder_id)
folder = await Folders.get_folder_by_id(folder_id)
if folder and (user.role == 'admin' or await has_folder_access(user.id, folder, 'read', db=None)):
files = await get_owner_accessible_folder_files(folder)
folder_items.update((entry.get('type'), entry.get('id')) for entry in files if isinstance(entry, dict))
items.extend(files)
for item in items:
query_result = None
@ -1425,21 +1225,8 @@ 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 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:
if chat and (user.role == 'admin' or chat.user_id == user.id):
messages_map = chat.chat.get('history', {}).get('messages', {})
message_id = chat.chat.get('history', {}).get('currentId')
@ -1447,10 +1234,7 @@ async def get_sources_from_items(
# Reconstruct the message list in order
message_list = get_message_list(messages_map, message_id)
message_history = '\n'.join(
[
f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n'
for m in message_list
]
[f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list]
)
# User has access to the chat
@ -1460,14 +1244,14 @@ async def get_sources_from_items(
}
elif item.get('type') == 'url':
content, docs = await get_content_from_url(request, item.get('url'))
content, docs = get_content_from_url(request, item.get('url'))
if docs:
query_result = {
'documents': [[content]],
'metadatas': [[{'url': item.get('url'), 'name': item.get('url')}]],
}
elif item.get('type') == 'file':
if item.get('context') == 'full' or bypass_embedding_and_retrieval:
if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
if item.get('file', {}).get('data', {}).get('content', ''):
# Manual Full Mode Toggle
# Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content")
@ -1489,7 +1273,6 @@ async def get_sources_from_items(
user.role == 'admin'
or file_object.user_id == user.id
or await has_access_to_file(item.get('id'), 'read', user)
or ('file', item.get('id')) in folder_items
):
query_result = {
'documents': [[file_object.data.get('content', '')]],
@ -1520,7 +1303,6 @@ async def get_sources_from_items(
user.role == 'admin'
or file_object.user_id == user.id
or await has_access_to_file(file_id, 'read', user)
or ('file', file_id) in folder_items
):
if item.get('legacy'):
collection_names.append(f'{file_id}')
@ -1540,64 +1322,51 @@ async def get_sources_from_items(
resource_id=knowledge_base.id,
permission='read',
)
or ('collection', item.get('id')) in folder_items
):
if (knowledge_base.meta or {}).get('source') == 'external':
query_result = await retrieve_external_knowledge(
request,
knowledge_base,
queries=queries,
count=k,
user=user,
)
extracted_collections.append(knowledge_base.id)
if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
if knowledge_base and (
user.role == 'admin'
or knowledge_base.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission='read',
)
):
files = await Knowledges.get_files_by_id(knowledge_base.id)
else:
if item.get('context') == 'full' or bypass_embedding_and_retrieval:
if knowledge_base and (
user.role == 'admin'
or knowledge_base.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission='read',
documents = []
metadatas = []
for file in files:
documents.append(file.data.get('content', ''))
metadatas.append(
{
'file_id': file.id,
'name': file.filename,
'source': file.filename,
}
)
or ('collection', item.get('id')) in folder_items
):
files = await Knowledges.get_files_by_id(knowledge_base.id)
documents = []
metadatas = []
for file in files:
documents.append(file.data.get('content', ''))
metadatas.append(
{
'file_id': file.id,
'name': file.filename,
'source': file.filename,
}
)
query_result = {
'documents': [documents],
'metadatas': [metadatas],
}
else:
if item.get('legacy'):
if BYPASS_RETRIEVAL_ACCESS_CONTROL:
collection_names = item.get('collection_names', [])
else:
# Legacy KB: item.collection_names is client-supplied.
# Validate against the KB's actual files to prevent
# cross-tenant collection name substitution.
files = await Knowledges.get_files_by_id(knowledge_base.id)
owned_names = {f'file-{f.id}' for f in files}
owned_names.add(knowledge_base.id)
valid_names = [n for n in (item.get('collection_names') or []) if n in owned_names]
collection_names = valid_names if valid_names else [knowledge_base.id]
query_result = {
'documents': [documents],
'metadatas': [metadatas],
}
else:
if item.get('legacy'):
if BYPASS_RETRIEVAL_ACCESS_CONTROL:
collection_names = item.get('collection_names', [])
else:
collection_names.append(item['id'])
# Legacy KB: item.collection_names is client-supplied.
# Validate against the KB's actual files to prevent
# cross-tenant collection name substitution.
files = await Knowledges.get_files_by_id(knowledge_base.id)
owned_names = {f'file-{f.id}' for f in files}
owned_names.add(knowledge_base.id)
valid_names = [n for n in (item.get('collection_names') or []) if n in owned_names]
collection_names = valid_names if valid_names else [knowledge_base.id]
else:
collection_names.append(item['id'])
elif item.get('docs'):
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
@ -1605,10 +1374,6 @@ async def get_sources_from_items(
'documents': [[doc.get('content') for doc in item.get('docs')]],
'metadatas': [[doc.get('metadata') for doc in item.get('docs')]],
}
elif item.get('type') == 'web_search' and item.get('collection_name'):
# Trusted server-generated collection; authorized by
# filter_accessible_collections below (allowlists web-search-*).
collection_names.append(item['collection_name'])
elif item.get('collection_name'):
if BYPASS_RETRIEVAL_ACCESS_CONTROL:
collection_names.append(item['collection_name'])
@ -1630,14 +1395,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('skipping %s as it has already been extracted', item)
log.debug(f'skipping {item} as it has already been extracted')
continue
# Filter out collections the user cannot read
if user and (item.get('type'), item.get('id')) not in folder_items:
if user:
collection_names = await filter_accessible_collections(collection_names, user)
if not collection_names:
log.debug('access denied for all collections in item %s', item)
log.debug(f'access denied for all collections in item {item}')
continue
try:
@ -1696,8 +1461,8 @@ def get_model_path(model: str, update_model: bool = False):
'local_files_only': local_files_only,
}
log.debug('model: %s', model)
log.debug('snapshot_kwargs: %s', snapshot_kwargs)
log.debug(f'model: {model}')
log.debug(f'snapshot_kwargs: {snapshot_kwargs}')
# Inspiration from upstream sentence_transformers
if os.path.exists(model) or ('\\' in model or model.count('/') > 1) and local_files_only:
@ -1712,7 +1477,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('model_repo_path: %s', model_repo_path)
log.debug(f'model_repo_path: {model_repo_path}')
return model_repo_path
except Exception as e:
log.exception(f'Cannot determine model snapshot path: {e}')

View file

@ -82,10 +82,6 @@ class AsyncVectorDBClient:
(e.g. already inside a worker thread)."""
return self._sync
@property
def supports_hybrid_search(self) -> bool:
return type(self._sync).hybrid_search is not VectorDBBase.hybrid_search
async def has_collection(self, collection_name: str) -> bool:
return await asyncio.to_thread(self._sync.has_collection, collection_name)
@ -107,25 +103,6 @@ class AsyncVectorDBClient:
) -> Optional[SearchResult]:
return await asyncio.to_thread(self._sync.search, collection_name, vectors, filter, limit)
async def hybrid_search(
self,
collection_name: str,
query: str,
vectors: List[List[Union[float, int]]],
filter: Optional[Dict] = None,
limit: int = 10,
hybrid_bm25_weight: float = 0.5,
) -> Optional[SearchResult]:
return await asyncio.to_thread(
self._sync.hybrid_search,
collection_name,
query,
vectors,
filter,
limit,
hybrid_bm25_weight,
)
async def query(
self,
collection_name: str,

View file

@ -3,7 +3,6 @@ from typing import Optional
import chromadb
from chromadb import Settings
from chromadb.errors import NotFoundError
from chromadb.utils.batch_utils import create_batches
from open_webui.config import (
CHROMA_CLIENT_AUTH_CREDENTIALS,
@ -57,11 +56,9 @@ class ChromaClient(VectorDBBase):
)
def has_collection(self, collection_name: str) -> bool:
try:
self.client.get_collection(name=collection_name)
return True
except NotFoundError:
return False
# Check if the collection exists based on the collection name.
collection_names = self.client.list_collections()
return collection_name in collection_names
def delete_collection(self, collection_name: str):
# Delete the collection based on the collection name.
@ -182,7 +179,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('Attempted to delete from non-existent collection %s. Ignoring.', collection_name)
log.debug(f'Attempted to delete from non-existent collection {collection_name}. Ignoring.')
pass
def reset(self):

View file

@ -3,7 +3,7 @@ NOTE: This vector database integration is community-supported and maintained on
"""
import ssl
from typing import Any, Optional
from typing import Optional
from elasticsearch import BadRequestError, Elasticsearch
from elasticsearch.helpers import bulk, scan
@ -23,13 +23,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
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}}
from open_webui.retrieval.vector.utils import process_metadata
class ElasticsearchClient(VectorDBBase):
@ -167,16 +161,12 @@ 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': filters}},
'query': {'bool': {'filter': [{'term': {'collection': collection_name}}]}},
'script': {
'source': "cosineSimilarity(params.vector, 'vector') + 1.0",
'params': {'vector': vectors[0]}, # Assuming single query vector

View file

@ -5,6 +5,7 @@ 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
@ -29,7 +30,6 @@ 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 = JSONCodec.loads(v)
j = json.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'),
JSONCodec.dumps(meta),
json.dumps(meta),
)
)
cur.executemany(sql, params)
@ -367,7 +367,7 @@ class MariaDBVectorClient(VectorDBBase):
emb,
collection_name,
item.get('text'),
JSONCodec.dumps(meta),
json.dumps(meta),
)
)
cur.executemany(sql, params)

View file

@ -2,9 +2,9 @@
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, Optional
from typing import Optional
from open_webui.config import (
MILVUS_DB,
@ -24,49 +24,12 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
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 open_webui.retrieval.vector.utils import process_metadata
from pymilvus import Collection, DataType, FieldSchema, connections
from pymilvus import MilvusClient as Client
from pymilvus.exceptions import MilvusException
log = logging.getLogger(__name__)
# Milvus caps stored text length (here the chunk lives under the JSON `data`
# 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):
def __init__(self):
@ -156,7 +119,7 @@ class MilvusClient(VectorDBBase):
index_type = MILVUS_INDEX_TYPE.upper()
metric_type = MILVUS_METRIC_TYPE.upper()
log.info('Using Milvus index type: %s, metric type: %s', index_type, metric_type)
log.info(f'Using Milvus index type: {index_type}, metric type: {metric_type}')
index_creation_params = {}
if index_type == 'HNSW':
@ -164,18 +127,18 @@ class MilvusClient(VectorDBBase):
'M': MILVUS_HNSW_M,
'efConstruction': MILVUS_HNSW_EFCONSTRUCTION,
}
log.info('HNSW params: %s', index_creation_params)
log.info(f'HNSW params: {index_creation_params}')
elif index_type == 'IVF_FLAT':
index_creation_params = {'nlist': MILVUS_IVF_FLAT_NLIST}
log.info('IVF_FLAT params: %s', index_creation_params)
log.info(f'IVF_FLAT params: {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('DISKANN params: %s', index_creation_params)
log.info(f'DISKANN params: {index_creation_params}')
elif index_type in ['FLAT', 'AUTOINDEX']:
log.info('Using %s index with no specific build-time params.', index_type)
log.info(f'Using {index_type} index with no specific build-time params.')
else:
log.warning(
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
@ -198,11 +161,7 @@ class MilvusClient(VectorDBBase):
index_params=index_params,
)
log.info(
"Successfully created collection '%s_%s' with index type '%s' and metric '%s'.",
self.collection_prefix,
collection_name,
index_type,
metric_type,
f"Successfully created collection '{self.collection_prefix}_{collection_name}' with index type '{index_type}' and metric '{metric_type}'."
)
def has_collection(self, collection_name: str) -> bool:
@ -224,9 +183,6 @@ 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.
@ -235,12 +191,13 @@ 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)
def query(self, collection_name: str, filter: dict, limit: int = -1):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
collection_name = collection_name.replace('-', '_')
if not self.has_collection(collection_name):
log.warning(f'Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}')
@ -255,20 +212,16 @@ class MilvusClient(VectorDBBase):
filter_string = ' && '.join(filter_expressions)
self.client.load_collection(collection_name=f'{self.collection_prefix}_{collection_name}')
collection = Collection(f'{self.collection_prefix}_{collection_name}')
collection.load()
try:
log.info(
"Querying collection %s_%s with filter: '%s', limit: %s",
self.collection_prefix,
collection_name,
filter_string,
limit,
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
)
iterator = self.client.query_iterator(
collection_name=f'{self.collection_prefix}_{collection_name}',
filter=filter_string,
iterator = collection.query_iterator(
expr=filter_string,
output_fields=[
'id',
'data',
@ -285,7 +238,7 @@ class MilvusClient(VectorDBBase):
break
all_results.extend(batch)
log.debug('Total results from query: %s', len(all_results))
log.debug(f'Total results from query: {len(all_results)}')
return self._result_to_get_result([all_results] if all_results else [[]])
except Exception as e:
@ -308,7 +261,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('Collection %s_%s does not exist. Creating now.', self.collection_prefix, collection_name)
log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist. Creating now.')
if not items:
log.error(
f'Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension.'
@ -316,37 +269,25 @@ 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('Inserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name)
data = []
for item in items:
text = item['text'] or ''
if len(text) > MILVUS_TEXT_MAX_LENGTH:
log.warning(f'Milvus: truncating text id={item["id"]} {len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars')
text = text[:MILVUS_TEXT_MAX_LENGTH]
data.append(
log.info(f'Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
return self.client.insert(
collection_name=f'{self.collection_prefix}_{collection_name}',
data=[
{
'id': item['id'],
'vector': item['vector'],
'data': {'text': text},
'data': {'text': item['text']},
'metadata': process_metadata(item['metadata']),
}
)
try:
return self.client.insert(
collection_name=f'{self.collection_prefix}_{collection_name}',
data=data,
)
except MilvusException as e:
log.error(f'Milvus insert failed for {self.collection_prefix}_{collection_name} ({len(items)} items): {e}')
raise
for item in items
],
)
def upsert(self, collection_name: str, items: list[VectorItem]):
# 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(
'Collection %s_%s does not exist for upsert. Creating now.', self.collection_prefix, collection_name
)
log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now.')
if not items:
log.error(
f'Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension.'
@ -356,29 +297,19 @@ class MilvusClient(VectorDBBase):
)
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
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 ''
if len(text) > MILVUS_TEXT_MAX_LENGTH:
log.warning(f'Milvus: truncating text id={item["id"]} {len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars')
text = text[:MILVUS_TEXT_MAX_LENGTH]
data.append(
log.info(f'Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.')
return self.client.upsert(
collection_name=f'{self.collection_prefix}_{collection_name}',
data=[
{
'id': item['id'],
'vector': item['vector'],
'data': {'text': text},
'data': {'text': item['text']},
'metadata': process_metadata(item['metadata']),
}
)
try:
return self.client.upsert(
collection_name=f'{self.collection_prefix}_{collection_name}',
data=data,
)
except MilvusException as e:
log.error(f'Milvus upsert failed for {self.collection_prefix}_{collection_name} ({len(items)} items): {e}')
raise
for item in items
],
)
def delete(
self,
@ -393,20 +324,15 @@ class MilvusClient(VectorDBBase):
return None
if ids:
log.info('Deleting items by IDs from %s_%s. IDs: %s', self.collection_prefix, collection_name, ids)
log.info(f'Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}')
return self.client.delete(
collection_name=f'{self.collection_prefix}_{collection_name}',
ids=ids,
)
elif filter:
filter_string = ' && '.join(
[f'metadata["{key}"] == {JSONCodec.dumps(value)}' for key, value in filter.items()]
)
filter_string = ' && '.join([f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items()])
log.info(
'Deleting items by filter from %s_%s. Filter: %s',
self.collection_prefix,
collection_name,
filter_string,
f'Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}'
)
return self.client.delete(
collection_name=f'{self.collection_prefix}_{collection_name}',
@ -428,7 +354,7 @@ class MilvusClient(VectorDBBase):
try:
self.client.drop_collection(collection_name=collection_name_full)
deleted_collections.append(collection_name_full)
log.info('Deleted collection: %s', collection_name_full)
log.info(f'Deleted collection: {collection_name_full}')
except Exception as e:
log.error(f'Error deleting collection {collection_name_full}: {e}')
log.info('Milvus reset complete. Deleted collections: %s', deleted_collections)
log.info(f'Milvus reset complete. Deleted collections: {deleted_collections}')

View file

@ -17,24 +17,24 @@ 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,
VectorDBBase,
VectorItem,
)
from pymilvus import DataType
from pymilvus import MilvusClient as Client
from pymilvus.exceptions import MilvusException
from pymilvus import (
Collection,
CollectionSchema,
DataType,
FieldSchema,
connections,
utility,
)
log = logging.getLogger(__name__)
RESOURCE_ID_FIELD = 'resource_id'
# Milvus VARCHAR hard cap for the `text` field (see _create_shared_collection).
# Chunks longer than this are truncated before insert so one oversized chunk
# can't fail the whole batch (and leave the file with zero embeddings).
MILVUS_TEXT_MAX_LENGTH = 65535
# Milvus expressions are SQL-like strings with no parameterized-query API;
# values get interpolated into single-quoted literals. Reject anything that
@ -65,7 +65,12 @@ class MilvusClient(VectorDBBase):
def __init__(self):
# Milvus collection names can only contain numbers, letters, and underscores.
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace('-', '_')
self.client = Client(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
connections.connect(
alias='default',
uri=MILVUS_URI,
token=MILVUS_TOKEN,
db_name=MILVUS_DB,
)
# Main collection types for multi-tenancy
self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories'
@ -106,66 +111,53 @@ class MilvusClient(VectorDBBase):
return self.KNOWLEDGE_COLLECTION, resource_id
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
schema = self.client.create_schema(auto_id=False, description='Shared collection for multi-tenancy')
schema.add_field(field_name='id', datatype=DataType.VARCHAR, is_primary=True, max_length=36)
schema.add_field(field_name='vector', datatype=DataType.FLOAT_VECTOR, dim=dimension)
schema.add_field(field_name='text', datatype=DataType.VARCHAR, max_length=MILVUS_TEXT_MAX_LENGTH)
schema.add_field(field_name='metadata', datatype=DataType.JSON)
schema.add_field(field_name=RESOURCE_ID_FIELD, datatype=DataType.VARCHAR, max_length=255)
fields = [
FieldSchema(
name='id',
dtype=DataType.VARCHAR,
is_primary=True,
auto_id=False,
max_length=36,
),
FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=dimension),
FieldSchema(name='text', dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name='metadata', dtype=DataType.JSON),
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields, 'Shared collection for multi-tenancy')
collection = Collection(mt_collection_name, schema)
index_build_params = {}
index_params = {
'metric_type': MILVUS_METRIC_TYPE,
'index_type': MILVUS_INDEX_TYPE,
'params': {},
}
if MILVUS_INDEX_TYPE == 'HNSW':
index_build_params = {
index_params['params'] = {
'M': MILVUS_HNSW_M,
'efConstruction': MILVUS_HNSW_EFCONSTRUCTION,
}
elif MILVUS_INDEX_TYPE == 'IVF_FLAT':
index_build_params = {'nlist': MILVUS_IVF_FLAT_NLIST}
index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST}
vector_index = self.client.prepare_index_params(
field_name='vector',
index_type=MILVUS_INDEX_TYPE,
metric_type=MILVUS_METRIC_TYPE,
params=index_build_params,
)
self.client.create_collection(collection_name=mt_collection_name, schema=schema)
self.client.create_index(collection_name=mt_collection_name, index_params=vector_index)
try:
# A Milvus server auto-selects the scalar index type from a parameterless call.
self.client.create_index(
collection_name=mt_collection_name,
index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD),
)
except MilvusException:
try:
self.client.create_index(
collection_name=mt_collection_name,
index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD, index_type='INVERTED'),
)
except MilvusException as e:
# 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('Created shared collection: %s', mt_collection_name)
collection.create_index('vector', index_params)
collection.create_index(RESOURCE_ID_FIELD)
log.info(f'Created shared collection: {mt_collection_name}')
return collection
def _ensure_collection(self, mt_collection_name: str, dimension: int):
if not self.client.has_collection(mt_collection_name):
if not utility.has_collection(mt_collection_name):
self._create_shared_collection(mt_collection_name, dimension)
def has_collection(self, collection_name: str) -> bool:
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not self.client.has_collection(mt_collection):
if not utility.has_collection(mt_collection):
return False
self.client.load_collection(mt_collection)
res = self.client.query(
collection_name=mt_collection,
filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=['id'],
limit=1,
)
collection = Collection(mt_collection)
collection.load()
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
return len(res) > 0
def upsert(self, collection_name: str, items: List[VectorItem]):
@ -175,35 +167,19 @@ class MilvusClient(VectorDBBase):
_validate_resource_id(resource_id)
dimension = len(items[0]['vector'])
self._ensure_collection(mt_collection, dimension)
collection = Collection(mt_collection)
entities = []
for item in items:
text = item['text'] or ''
if len(text) > MILVUS_TEXT_MAX_LENGTH:
log.warning(
f'Milvus: truncating text id={item["id"]} '
f'{len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars '
f'(collection={mt_collection}, resource_id={resource_id})'
)
text = text[:MILVUS_TEXT_MAX_LENGTH]
entities.append(
{
'id': item['id'],
'vector': item['vector'],
'text': text,
'metadata': item['metadata'],
RESOURCE_ID_FIELD: resource_id,
}
)
try:
self.client.insert(collection_name=mt_collection, data=entities)
except MilvusException as e:
log.error(
f'Milvus insert failed (collection={mt_collection}, '
f'resource_id={resource_id}, items={len(entities)}): {e}'
)
raise
entities = [
{
'id': item['id'],
'vector': item['vector'],
'text': item['text'],
'metadata': item['metadata'],
RESOURCE_ID_FIELD: resource_id,
}
for item in items
]
collection.insert(entities)
def search(
self,
@ -217,19 +193,19 @@ class MilvusClient(VectorDBBase):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not self.client.has_collection(mt_collection):
if not utility.has_collection(mt_collection):
return None
self.client.load_collection(mt_collection)
collection = Collection(mt_collection)
collection.load()
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'", *_metadata_exprs(filter)]
results = self.client.search(
collection_name=mt_collection,
search_params = {'metric_type': MILVUS_METRIC_TYPE, 'params': {}}
results = collection.search(
data=vectors,
anns_field='vector',
search_params={'metric_type': MILVUS_METRIC_TYPE, 'params': {}},
param=search_params,
limit=limit,
filter=' and '.join(expr),
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=['id', 'text', 'metadata'],
)
@ -237,11 +213,10 @@ class MilvusClient(VectorDBBase):
for hits in results:
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
for hit in hits:
entity = hit.get('entity', {})
batch_ids.append(entity.get('id'))
batch_docs.append(entity.get('text'))
batch_metadatas.append(entity.get('metadata'))
batch_dists.append(hit.get('distance'))
batch_ids.append(hit.entity.get('id'))
batch_docs.append(hit.entity.get('text'))
batch_metadatas.append(hit.entity.get('metadata'))
batch_dists.append(hit.distance)
ids.append(batch_ids)
documents.append(batch_docs)
metadatas.append(batch_metadatas)
@ -257,9 +232,11 @@ class MilvusClient(VectorDBBase):
):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not self.client.has_collection(mt_collection):
if not utility.has_collection(mt_collection):
return
collection = Collection(mt_collection)
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if ids:
# Milvus expects a string list for 'in' operator
@ -271,28 +248,30 @@ class MilvusClient(VectorDBBase):
_validate_metadata_key(key)
expr.append(f"metadata['{key}'] == '{_escape_milvus_string(str(value))}'")
self.client.delete(collection_name=mt_collection, filter=' and '.join(expr))
collection.delete(' and '.join(expr))
def reset(self):
for collection_name in self.shared_collections:
if self.client.has_collection(collection_name):
self.client.drop_collection(collection_name)
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
def delete_collection(self, collection_name: str):
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not self.client.has_collection(mt_collection):
if not utility.has_collection(mt_collection):
return
self.client.delete(collection_name=mt_collection, filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'")
collection = Collection(mt_collection)
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]:
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
_validate_resource_id(resource_id)
if not self.client.has_collection(mt_collection):
if not utility.has_collection(mt_collection):
return None
self.client.load_collection(mt_collection)
collection = Collection(mt_collection)
collection.load()
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if filter:
@ -307,9 +286,8 @@ class MilvusClient(VectorDBBase):
else:
raise TypeError(f'Unsupported Milvus filter value type for key {key!r}: {type(value).__name__}')
iterator = self.client.query_iterator(
collection_name=mt_collection,
filter=' and '.join(expr),
iterator = collection.query_iterator(
expr=' and '.join(expr),
output_fields=['id', 'text', 'metadata'],
limit=limit if limit else -1,
)

View file

@ -2,6 +2,7 @@
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
@ -62,18 +63,20 @@ 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 iter_filter_conditions, process_metadata
from open_webui.retrieval.vector.utils import 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):
@ -86,12 +89,6 @@ 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:
@ -185,7 +182,7 @@ class OpenGaussClient(VectorDBBase):
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
self.session.commit()
log.info("Inserting %s items into collection '%s'.", len(new_items), collection_name)
log.info(f"Inserting {len(new_items)} items into collection '{collection_name}'.")
except Exception as e:
self.session.rollback()
log.exception(f'Failed to insert data: {e}')
@ -211,7 +208,7 @@ class OpenGaussClient(VectorDBBase):
)
self.session.add(new_chunk)
self.session.commit()
log.info("Inserting/updating %s items in collection '%s'.", len(items), collection_name)
log.info(f"Inserting/updating {len(items)} items in collection '{collection_name}'.")
except Exception as e:
self.session.rollback()
log.exception(f'Failed to insert or update data.: {e}')
@ -248,15 +245,10 @@ 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(*where_clauses)
.where(DocumentChunk.collection_name == collection_name)
.order_by(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
)
if limit is not None:
@ -361,7 +353,7 @@ class OpenGaussClient(VectorDBBase):
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
deleted = query.delete(synchronize_session=False)
self.session.commit()
log.info("Deleted %s items from collection '%s'", deleted, collection_name)
log.info(f"Deleted {deleted} items from collection '{collection_name}'")
except Exception as e:
self.session.rollback()
log.exception(f'Failed to delete data: {e}')
@ -371,7 +363,7 @@ class OpenGaussClient(VectorDBBase):
try:
deleted = self.session.query(DocumentChunk).delete()
self.session.commit()
log.info('Reset completed. Deleted %s items', deleted)
log.info(f'Reset completed. Deleted {deleted} items')
except Exception as e:
self.session.rollback()
log.exception(f'Reset failed: {e}')
@ -395,4 +387,4 @@ class OpenGaussClient(VectorDBBase):
def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name)
log.info("Collection '%s' has been deleted", collection_name)
log.info(f"Collection '{collection_name}' has been deleted")

View file

@ -2,7 +2,7 @@
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from typing import Any, Optional
from typing import Optional
from open_webui.config import (
OPENSEARCH_CERT_VERIFY,
@ -17,17 +17,11 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
from open_webui.retrieval.vector.utils import 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'
@ -127,8 +121,6 @@ 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
@ -138,7 +130,7 @@ class OpenSearchClient(VectorDBBase):
'_source': ['text', 'metadata'],
'query': {
'script_score': {
'query': {'bool': {'filter': filter_clauses}} if filter_clauses else {'match_all': {}},
'query': {'match_all': {}},
'script': {
'source': '(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0',
'params': {

View file

@ -32,7 +32,6 @@ import array
import json
import logging
import os
import re
import threading
import time
from decimal import Decimal
@ -57,29 +56,8 @@ 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):
@ -115,10 +93,10 @@ class Oracle23aiClient(VectorDBBase):
self._create_dbcs_pool()
dsn = ORACLE_DB_DSN
log.info('Creating Connection Pool [%s:**@%s]', ORACLE_DB_USER, dsn)
log.info(f'Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]')
with self.get_connection() as connection:
log.info('Connection version: %s', connection.version)
log.info(f'Connection version: {connection.version}')
self._initialize_database(connection)
log.info('Oracle Vector Search initialization complete.')
@ -180,7 +158,7 @@ class Oracle23aiClient(VectorDBBase):
if attempt < max_retries - 1:
wait_time = 2**attempt
log.info('Retrying in %s seconds...', wait_time)
log.info(f'Retrying in {wait_time} seconds...')
time.sleep(wait_time)
else:
raise
@ -205,7 +183,7 @@ class Oracle23aiClient(VectorDBBase):
thread = threading.Thread(target=_monitor, daemon=True)
thread.start()
log.info('Started DB health monitor every %s seconds.', interval_seconds)
log.info(f'Started DB health monitor every {interval_seconds} seconds.')
def _reconnect_pool(self):
"""
@ -412,7 +390,7 @@ class Oracle23aiClient(VectorDBBase):
Returns:
Dict: Metadata dictionary
"""
return JSONCodec.loads(json_str) if json_str else {}
return json.loads(json_str) if json_str else {}
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
@ -433,7 +411,7 @@ class Oracle23aiClient(VectorDBBase):
... ]
>>> client.insert("my_collection", items)
"""
log.info("Inserting %s items into collection '%s'.", len(items), collection_name)
log.info(f"Inserting {len(items)} items into collection '{collection_name}'.")
with self.get_connection() as connection:
try:
@ -458,7 +436,7 @@ class Oracle23aiClient(VectorDBBase):
)
connection.commit()
log.info("Successfully inserted %s items into collection '%s'.", len(items), collection_name)
log.info(f"Successfully inserted {len(items)} items into collection '{collection_name}'.")
except Exception as e:
connection.rollback()
@ -487,7 +465,7 @@ class Oracle23aiClient(VectorDBBase):
... ]
>>> client.upsert("my_collection", items)
"""
log.info("Upserting %s items into collection '%s'.", len(items), collection_name)
log.info(f"Upserting {len(items)} items into collection '{collection_name}'.")
with self.get_connection() as connection:
try:
@ -526,7 +504,7 @@ class Oracle23aiClient(VectorDBBase):
)
connection.commit()
log.info("Successfully upserted %s items into collection '%s'.", len(items), collection_name)
log.info(f"Successfully upserted {len(items)} items into collection '{collection_name}'.")
except Exception as e:
connection.rollback()
@ -562,7 +540,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("Searching items from collection '%s' with limit %s.", collection_name, limit)
log.info(f"Searching items from collection '{collection_name}' with limit {limit}.")
try:
if not vectors:
@ -570,7 +548,6 @@ 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)]
@ -583,12 +560,12 @@ class Oracle23aiClient(VectorDBBase):
vector_blob = self._vector_to_blob(vector)
cursor.execute(
f"""
SELECT dc.id, dc.text,
"""
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{filter_clause}
WHERE dc.collection_name = :collection_name
ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE)
FETCH APPROX FIRST :limit ROWS ONLY
""",
@ -596,7 +573,6 @@ class Oracle23aiClient(VectorDBBase):
'query_vector': vector_blob,
'collection_name': collection_name,
'limit': limit,
**filter_params,
},
)
@ -610,7 +586,7 @@ class Oracle23aiClient(VectorDBBase):
metadatas[qid].append(self._json_to_metadata(metadata_str))
distances[qid].append(float(row[3]))
log.info('Search completed. Found %s total results.', sum(len(ids[i]) for i in range(num_queries)))
log.info(f'Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results.')
return SearchResult(ids=ids, distances=distances, documents=documents, metadatas=metadatas)
@ -639,7 +615,7 @@ class Oracle23aiClient(VectorDBBase):
>>> if results:
... print(f"Found {len(results.ids[0])} matching documents")
"""
log.info("Querying items from collection '%s' with filters.", collection_name)
log.info(f"Querying items from collection '{collection_name}' with filters.")
try:
limit = limit or 100
@ -679,7 +655,7 @@ class Oracle23aiClient(VectorDBBase):
]
]
log.info('Query completed. Found %s results.', len(results))
log.info(f'Query completed. Found {len(results)} results.')
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
@ -770,7 +746,7 @@ class Oracle23aiClient(VectorDBBase):
>>> # Or delete by metadata filter
>>> client.delete("my_collection", filter={"source": "deprecated_source"})
"""
log.info("Deleting items from collection '%s'.", collection_name)
log.info(f"Deleting items from collection '{collection_name}'.")
try:
query = 'DELETE FROM document_chunk WHERE collection_name = :collection_name'
@ -795,7 +771,7 @@ class Oracle23aiClient(VectorDBBase):
deleted = cursor.rowcount
connection.commit()
log.info("Deleted %s items from collection '%s'.", deleted, collection_name)
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
except Exception as e:
log.exception(f'Error during delete: {e}')
@ -823,7 +799,7 @@ class Oracle23aiClient(VectorDBBase):
deleted = cursor.rowcount
connection.commit()
log.info("Reset complete. Deleted %s items from 'document_chunk' table.", deleted)
log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.")
except Exception as e:
log.exception(f'Error during reset: {e}')
@ -898,7 +874,7 @@ class Oracle23aiClient(VectorDBBase):
>>> client = Oracle23aiClient()
>>> client.delete_collection("obsolete_collection")
"""
log.info("Deleting collection '%s'.", collection_name)
log.info(f"Deleting collection '{collection_name}'.")
try:
with self.get_connection() as connection:
@ -914,7 +890,7 @@ class Oracle23aiClient(VectorDBBase):
deleted = cursor.rowcount
connection.commit()
log.info("Collection '%s' deleted. Removed %s items.", collection_name, deleted)
log.info(f"Collection '{collection_name}' deleted. Removed {deleted} items.")
except Exception as e:
log.exception(f"Error deleting collection '{collection_name}': {e}")

View file

@ -1,3 +1,4 @@
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
@ -17,15 +18,13 @@ 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,
VectorDBBase,
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.retrieval.vector.utils import process_metadata
from open_webui.utils.misc import sanitize_text_for_db
from pgvector.sqlalchemy import HALFVEC, Vector
from sqlalchemy import (
@ -88,6 +87,8 @@ 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):
@ -106,7 +107,6 @@ 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)
@ -153,7 +153,6 @@ class PgvectorClient(VectorDBBase):
index_method, index_options = self._vector_index_configuration()
self._ensure_vector_index(index_method, index_options)
self._ensure_text_search_index()
self.session.execute(
text(
@ -237,19 +236,6 @@ class PgvectorClient(VectorDBBase):
f' {index_options}' if index_options else '',
)
def _ensure_text_search_index(self) -> None:
if PGVECTOR_PGCRYPTO:
return
self.session.execute(
text("""
CREATE INDEX IF NOT EXISTS idx_document_chunk_text_search
ON document_chunk
USING GIN (to_tsvector('simple', coalesce(text, '')));
""")
)
log.info("Ensured text search index 'idx_document_chunk_text_search'.")
def check_vector_length(self) -> None:
"""
Check if the VECTOR_LENGTH matches the existing vector column dimension in the database.
@ -303,7 +289,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(JSONCodec.dumps(item['metadata']))
json_metadata = sanitize_text_for_db(json.dumps(item['metadata']))
item_text = sanitize_text_for_db(item['text'])
self.session.execute(
text("""
@ -326,7 +312,7 @@ class PgvectorClient(VectorDBBase):
},
)
self.session.commit()
log.info("Encrypted & inserted %s into '%s'", len(items), collection_name)
log.info(f"Encrypted & inserted {len(items)} into '{collection_name}'")
else:
new_items = []
@ -342,7 +328,7 @@ class PgvectorClient(VectorDBBase):
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
self.session.commit()
log.info("Inserted %s items into collection '%s'.", len(new_items), collection_name)
log.info(f"Inserted {len(new_items)} items into collection '{collection_name}'.")
except Exception as e:
self.session.rollback()
log.exception(f'Error during insert: {e}')
@ -354,7 +340,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(JSONCodec.dumps(item['metadata']))
json_metadata = sanitize_text_for_db(json.dumps(item['metadata']))
item_text = sanitize_text_for_db(item['text'])
self.session.execute(
text("""
@ -381,7 +367,7 @@ class PgvectorClient(VectorDBBase):
},
)
self.session.commit()
log.info("Encrypted & upserted %s into '%s'", len(items), collection_name)
log.info(f"Encrypted & upserted {len(items)} into '{collection_name}'")
else:
for item in items:
vector = self.adjust_vector_length(item['vector'])
@ -401,7 +387,7 @@ class PgvectorClient(VectorDBBase):
)
self.session.add(new_chunk)
self.session.commit()
log.info("Upserted %s items into collection '%s'.", len(items), collection_name)
log.info(f"Upserted {len(items)} items into collection '{collection_name}'.")
except Exception as e:
self.session.rollback()
log.exception(f'Error during upsert: {e}')
@ -535,71 +521,6 @@ class PgvectorClient(VectorDBBase):
log.exception(f'Error during search: {e}')
return None
def hybrid_search(
self,
collection_name: str,
query: str,
vectors: List[List[float]],
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
hybrid_bm25_weight: float = 0.5,
) -> Optional[SearchResult]:
if PGVECTOR_PGCRYPTO or filter:
return None
try:
limit = max(1, limit)
vectors = [self.adjust_vector_length(vector) for vector in vectors] if vectors else []
num_queries = len(vectors) if vectors else 1
bm25_weight = min(max(hybrid_bm25_weight, 0.0), 1.0)
vector_weight = 1.0 - bm25_weight
vector_result = None
if vector_weight > 0 and vectors:
vector_result = self.search(collection_name=collection_name, vectors=vectors, limit=limit)
fts_results = []
if bm25_weight > 0 and query and query.strip():
fts_rows = self.session.execute(
text("""
WITH fts_query AS (
SELECT plainto_tsquery('simple', :query) AS query
)
SELECT
document_chunk.id AS id,
document_chunk.text AS text,
document_chunk.vmetadata AS vmetadata,
ts_rank_cd(
to_tsvector('simple', coalesce(document_chunk.text, '')),
fts_query.query
) AS rank
FROM document_chunk, fts_query
WHERE document_chunk.collection_name = :collection_name
AND to_tsvector('simple', coalesce(document_chunk.text, '')) @@ fts_query.query
ORDER BY rank DESC
LIMIT :limit
"""),
{
'collection_name': collection_name,
'query': query,
'limit': limit,
},
)
fts_results = [dict(row) for row in fts_rows.mappings().all()]
self.session.rollback()
return merge_hybrid_search_results(
vector_result=vector_result,
fts_results=fts_results,
num_queries=num_queries,
limit=limit,
hybrid_bm25_weight=hybrid_bm25_weight,
)
except Exception as e:
self.session.rollback()
log.exception(f'Error during hybrid search: {e}')
return None
def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]:
try:
if PGVECTOR_PGCRYPTO:
@ -712,7 +633,7 @@ class PgvectorClient(VectorDBBase):
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
deleted = query.delete(synchronize_session=False)
self.session.commit()
log.info("Deleted %s items from collection '%s'.", deleted, collection_name)
log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
except Exception as e:
self.session.rollback()
log.exception(f'Error during delete: {e}')
@ -722,7 +643,7 @@ class PgvectorClient(VectorDBBase):
try:
deleted = self.session.query(DocumentChunk).delete()
self.session.commit()
log.info("Reset complete. Deleted %s items from 'document_chunk' table.", deleted)
log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.")
except Exception as e:
self.session.rollback()
log.exception(f'Error during reset: {e}')
@ -746,4 +667,4 @@ class PgvectorClient(VectorDBBase):
def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name)
log.info("Collection '%s' deleted.", collection_name)
log.info(f"Collection '{collection_name}' deleted.")

View file

@ -35,7 +35,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import normalize_filter, process_metadata
from open_webui.retrieval.vector.utils import process_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
@ -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("Creating Pinecone index '%s'...", self.index_name)
log.info(f"Creating Pinecone index '{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("Successfully created Pinecone index '%s'", self.index_name)
log.info(f"Successfully created Pinecone index '{self.index_name}'")
else:
log.info("Using existing Pinecone index '%s'", self.index_name)
log.info(f"Using existing Pinecone index '{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("Collection '%s' deleted (all vectors removed).", collection_name_with_prefix)
log.info(f"Collection '{collection_name_with_prefix}' deleted (all vectors removed).")
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('Insert of %s vectors took %.2f seconds', len(points), elapsed)
log.debug(f'Insert of {len(points)} vectors took {elapsed:.2f} seconds')
log.info(
"Successfully inserted %s vectors in parallel batches into '%s'", len(points), collection_name_with_prefix
f"Successfully inserted {len(points)} vectors in parallel batches into '{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('Upsert of %s vectors took %.2f seconds', len(points), elapsed)
log.debug(f'Upsert of {len(points)} vectors took {elapsed:.2f} seconds')
log.info(
"Successfully upserted %s vectors in parallel batches into '%s'", len(points), collection_name_with_prefix
f"Successfully upserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'"
)
async def insert_async(self, collection_name: str, items: List[VectorItem]) -> None:
@ -326,9 +326,7 @@ class PineconeClient(VectorDBBase):
if isinstance(result, Exception):
log.error(f'Error in async insert batch: {result}')
raise result
log.info(
"Successfully async inserted %s vectors in batches into '%s'", len(points), collection_name_with_prefix
)
log.info(f"Successfully async inserted {len(points)} vectors in batches into '{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."""
@ -348,9 +346,7 @@ class PineconeClient(VectorDBBase):
if isinstance(result, Exception):
log.error(f'Error in async upsert batch: {result}')
raise result
log.info(
"Successfully async upserted %s vectors in batches into '%s'", len(points), collection_name_with_prefix
)
log.info(f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'")
def search(
self,
@ -372,15 +368,13 @@ 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=pinecone_filter,
filter={'collection_name': collection_name_with_prefix},
)
matches = getattr(query_response, 'matches', []) or []
@ -480,10 +474,8 @@ 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(
"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)
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}'")
elif filter:
# Combine user filter with collection_name
@ -492,7 +484,7 @@ class PineconeClient(VectorDBBase):
pinecone_filter.update(filter)
# Delete by metadata filter
self.index.delete(filter=pinecone_filter)
log.info("Successfully deleted vectors by filter from '%s'", collection_name_with_prefix)
log.info(f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'")
else:
log.warning('No ids or filter provided for delete operation')

View file

@ -3,7 +3,7 @@ NOTE: This vector database integration is community-supported and maintained on
"""
import logging
from typing import Any, Optional
from typing import Optional
from urllib.parse import urlparse
from open_webui.config import (
@ -22,7 +22,6 @@ 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
@ -32,11 +31,6 @@ 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
@ -125,7 +119,7 @@ class QdrantClient(VectorDBBase):
on_disk=self.QDRANT_ON_DISK,
),
)
log.info('collection %s successfully created!', collection_name_with_prefix)
log.info(f'collection {collection_name_with_prefix} successfully created!')
def _create_collection_if_not_exists(self, collection_name, dimension):
if not self.has_collection(collection_name=collection_name):
@ -158,13 +152,10 @@ 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(

View file

@ -23,7 +23,6 @@ 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
@ -40,9 +39,8 @@ 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, 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)
def _metadata_filter(key: str, value: Any) -> models.FieldCondition:
return models.FieldCondition(key=f'metadata.{key}', match=models.MatchValue(value=value))
class QdrantClient(VectorDBBase):
@ -150,7 +148,7 @@ class QdrantClient(VectorDBBase):
m=0,
),
)
log.info('Multi-tenant collection %s created with dimension %s!', mt_collection_name, dimension)
log.info(f'Multi-tenant collection {mt_collection_name} created with dimension {dimension}!')
self.client.create_payload_index(
collection_name=mt_collection_name,
@ -226,7 +224,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("Collection %s doesn't exist, nothing to delete", mt_collection)
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
return None
must_conditions = [_tenant_filter(tenant_id)]
@ -236,7 +234,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, '$eq', v) for k, v in filter.items()]
must_conditions += [_metadata_filter(k, v) for k, v in filter.items()]
return self.client.delete(
collection_name=mt_collection,
@ -257,17 +255,15 @@ 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("Collection %s doesn't exist, search returns None", mt_collection)
log.debug(f"Collection {mt_collection} doesn't exist, search returns None")
return None
conditions = [_tenant_filter(tenant_id)]
if filter:
conditions.extend(_metadata_filter(key, op, value) for key, op, value in iter_filter_conditions(filter))
tenant_filter = _tenant_filter(tenant_id)
query_response = self.client.query_points(
collection_name=mt_collection,
query=vectors[0],
limit=limit,
query_filter=models.Filter(must=conditions),
query_filter=models.Filter(must=[tenant_filter]),
)
get_result = self._result_to_get_result(query_response.points)
return SearchResult(
@ -285,12 +281,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("Collection %s doesn't exist, query returns None", mt_collection)
log.debug(f"Collection {mt_collection} doesn't exist, query returns None")
return None
if limit is None:
limit = NO_LIMIT
tenant_filter = _tenant_filter(tenant_id)
field_conditions = [_metadata_filter(k, '$eq', v) for k, v in filter.items()]
field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
points = self.client.scroll(
collection_name=mt_collection,
@ -307,7 +303,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("Collection %s doesn't exist, get returns None", mt_collection)
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
return None
tenant_filter = _tenant_filter(tenant_id)
points = self.client.scroll(
@ -354,7 +350,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("Collection %s doesn't exist, nothing to delete", mt_collection)
log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
return None
self.client.delete(
collection_name=mt_collection,

View file

@ -13,7 +13,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import metadata_matches_filter, normalize_filter, process_metadata
from open_webui.retrieval.vector.utils import 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("S3Vector client initialized for bucket '%s' in region '%s'", self.bucket_name, self.region)
log.info(f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{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("Index '%s' already exists, skipping creation", index_name)
log.debug(f"Index '{index_name}' already exists, skipping creation")
return
try:
@ -70,9 +70,7 @@ class S3VectorClient(VectorDBBase):
]
},
)
log.info(
'Created S3 index: %s (dim=%s, type=%s, metric=%s)', index_name, dimension, data_type, distance_metric
)
log.info(f'Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})')
except Exception as e:
log.error(f"Error creating S3 index '{index_name}': {e}")
raise
@ -139,9 +137,9 @@ class S3VectorClient(VectorDBBase):
return
try:
log.info("Deleting collection '%s'", collection_name)
log.info(f"Deleting collection '{collection_name}'")
self.client.delete_index(vectorBucketName=self.bucket_name, indexName=collection_name)
log.info("Successfully deleted collection '%s'", collection_name)
log.info(f"Successfully deleted collection '{collection_name}'")
except Exception as e:
log.error(f"Error deleting collection '{collection_name}': {e}")
raise
@ -158,7 +156,7 @@ class S3VectorClient(VectorDBBase):
try:
if not self.has_collection(collection_name):
log.info("Index '%s' does not exist. Creating index.", collection_name)
log.info(f"Index '{collection_name}' does not exist. Creating index.")
self._create_index(
index_name=collection_name,
dimension=dimension,
@ -204,11 +202,9 @@ class S3VectorClient(VectorDBBase):
indexName=collection_name,
vectors=batch,
)
log.info(
"Inserted batch %s: %s vectors into index '%s'.", i // batch_size + 1, len(batch), collection_name
)
log.info(f"Inserted batch {i // batch_size + 1}: {len(batch)} vectors into index '{collection_name}'.")
log.info("Completed insertion of %s vectors into index '%s'.", len(vectors), collection_name)
log.info(f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'.")
except Exception as e:
log.error(f'Error inserting vectors: {e}')
raise
@ -222,11 +218,11 @@ class S3VectorClient(VectorDBBase):
return
dimension = len(items[0]['vector'])
log.info('Upsert dimension: %s', dimension)
log.info(f'Upsert dimension: {dimension}')
try:
if not self.has_collection(collection_name):
log.info("Index '%s' does not exist. Creating index for upsert.", collection_name)
log.info(f"Index '{collection_name}' does not exist. Creating index for upsert.")
self._create_index(
index_name=collection_name,
dimension=dimension,
@ -268,14 +264,10 @@ class S3VectorClient(VectorDBBase):
batch = vectors[i : i + batch_size]
if i == 0: # Log sample info for first batch only
log.info(
'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']),
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"])}'
)
else:
log.info('Upserting batch %s: %s vectors.', i // batch_size + 1, len(batch))
log.info(f'Upserting batch {i // batch_size + 1}: {len(batch)} vectors.')
self.client.put_vectors(
vectorBucketName=self.bucket_name,
@ -283,7 +275,7 @@ class S3VectorClient(VectorDBBase):
vectors=batch,
)
log.info("Completed upsert of %s vectors into index '%s'.", len(vectors), collection_name)
log.info(f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'.")
except Exception as e:
log.error(f'Error upserting vectors: {e}')
raise
@ -308,8 +300,7 @@ class S3VectorClient(VectorDBBase):
return None
try:
log.info("Searching collection '%s' with %s query vectors, limit=%s", collection_name, len(vectors), limit)
vector_filter = normalize_filter(filter)
log.info(f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}")
# Initialize result lists
all_ids = []
@ -319,23 +310,20 @@ class S3VectorClient(VectorDBBase):
# Process each query vector
for i, query_vector in enumerate(vectors):
log.debug('Processing query vector %s/%s', i + 1, len(vectors))
log.debug(f'Processing query vector {i + 1}/{len(vectors)}')
# Prepare the query vector in S3 Vector format
query_vector_dict = {'float32': [float(x) for x in query_vector]}
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)
# 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,
)
# Process results for this query
query_ids = []
@ -350,9 +338,6 @@ 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):
@ -377,7 +362,7 @@ class S3VectorClient(VectorDBBase):
all_metadatas.append(query_metadatas)
all_distances.append(query_distances)
log.info('Search completed. Found results for %s queries', len(all_ids))
log.info(f'Search completed. Found results for {len(all_ids)} queries')
# Return SearchResult format
return SearchResult(
@ -417,7 +402,7 @@ class S3VectorClient(VectorDBBase):
return self.get(collection_name)
try:
log.info("Querying collection '%s' with filter: %s", collection_name, filter)
log.info(f"Querying collection '{collection_name}' with filter: {filter}")
# For S3 Vector, we need to use list_vectors and then filter results
# Since S3 Vector may not support complex server-side filtering,
@ -452,7 +437,7 @@ class S3VectorClient(VectorDBBase):
if limit and len(filtered_ids) >= limit:
break
log.info('Filter applied: %s vectors match out of %s total', len(filtered_ids), len(all_ids))
log.info(f'Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total')
# Return GetResult format
if filtered_ids:
@ -487,7 +472,7 @@ class S3VectorClient(VectorDBBase):
return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
try:
log.info("Retrieving all vectors from collection '%s'", collection_name)
log.info(f"Retrieving all vectors from collection '{collection_name}'")
# Initialize result lists
all_ids = []
@ -536,7 +521,7 @@ class S3VectorClient(VectorDBBase):
)
# Log the actual content for debugging
log.debug('Document text preview (first 200 chars): %s', str(document_text)[:200])
log.debug(f'Document text preview (first 200 chars): {str(document_text)[:200]}')
else:
document_text = vector_id
@ -549,7 +534,7 @@ class S3VectorClient(VectorDBBase):
if not next_token:
break
log.info("Retrieved %s vectors from collection '%s'", len(all_ids), collection_name)
log.info(f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'")
# Return in GetResult format
# The Open WebUI GetResult expects lists of lists, so we wrap each list
@ -591,17 +576,17 @@ class S3VectorClient(VectorDBBase):
try:
if ids:
# Delete by specific vector IDs/keys
log.info("Deleting %s vectors by IDs from collection '%s'", len(ids), collection_name)
log.info(f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'")
self.client.delete_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
keys=ids,
)
log.info("Deleted %s vectors from index '%s'", len(ids), collection_name)
log.info(f"Deleted {len(ids)} vectors from index '{collection_name}'")
elif filter:
# Handle filter-based deletion
log.info("Deleting vectors by filter from collection '%s': %s", collection_name, filter)
log.info(f"Deleting vectors by filter from collection '{collection_name}': {filter}")
# If this is a knowledge collection and we have a file_id filter,
# also clean up the corresponding file-specific collection
@ -610,8 +595,7 @@ class S3VectorClient(VectorDBBase):
file_collection_name = f'file-{file_id}'
if self.has_collection(file_collection_name):
log.info(
"Found related file-specific collection '%s', deleting it to prevent duplicates",
file_collection_name,
f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates"
)
self.delete_collection(file_collection_name)
@ -620,7 +604,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('Found %s vectors matching filter, deleting them', len(matching_ids))
log.info(f'Found {len(matching_ids)} vectors matching filter, deleting them')
# Delete the matching vectors by ID
self.client.delete_vectors(
@ -628,7 +612,7 @@ class S3VectorClient(VectorDBBase):
indexName=collection_name,
keys=matching_ids,
)
log.info("Deleted %s vectors from index '%s' using filter", len(matching_ids), collection_name)
log.info(f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter")
else:
log.warning('No vectors found matching the filter criteria')
else:
@ -661,11 +645,11 @@ class S3VectorClient(VectorDBBase):
try:
self.client.delete_index(vectorBucketName=self.bucket_name, indexName=index_name)
deleted_count += 1
log.info('Deleted index: %s', index_name)
log.info(f'Deleted index: {index_name}')
except Exception as e:
log.error(f"Error deleting index '{index_name}': {e}")
log.info('Reset completed: deleted %s indexes', deleted_count)
log.info(f'Reset completed: deleted {deleted_count} indexes')
except Exception as e:
log.error(f'Error during reset: {e}')

View file

@ -2,6 +2,7 @@
# 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
@ -23,7 +24,6 @@ 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('Valkey core version: %s', self._format_version(version) if version else 'unknown')
log.info(f'Valkey core version: {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('valkey-search version: %s', self._format_version(search_version) if search_version else 'unknown')
log.info(f'valkey-search version: {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,15 +385,12 @@ class ValkeyClient(VectorDBBase):
try:
g['glide_ft'].create(self.client, index_name, schema, options)
log.info(
'Created Valkey index %s with dimension=%s, type=%s, metric=%s',
index_name,
dimension,
self.index_type,
self.distance_metric,
f'Created Valkey index {index_name} with dimension={dimension}, '
f'type={self.index_type}, metric={self.distance_metric}'
)
except g['RequestError'] as e:
if 'already exists' in str(e).lower():
log.debug('Index %s already exists, skipping creation.', index_name)
log.debug(f'Index {index_name} already exists, skipping creation.')
else:
raise
@ -459,9 +456,9 @@ class ValkeyClient(VectorDBBase):
index_name = self._index_name(collection_name)
try:
self._g['glide_ft'].dropindex(self.client, index_name)
log.info('Dropped index %s', index_name)
log.info(f'Dropped index {index_name}')
except self._g['RequestError'] as e:
log.debug('Could not drop index %s: %s', index_name, e)
log.debug(f'Could not drop index {index_name}: {e}')
self._delete_keys_by_prefix(self._key_prefix(collection_name))
@ -485,7 +482,7 @@ class ValkeyClient(VectorDBBase):
'id': item['id'],
'vector': _vector_to_bytes(item['vector']),
'text': item['text'],
'metadata_json': JSONCodec.dumps(metadata),
'metadata_json': json.dumps(metadata),
# `or ''` prevents indexing literal 'None' as a TAG value, which would
# poison $ne / equality queries.
'hash': str(metadata.get('hash') or ''),
@ -495,7 +492,7 @@ class ValkeyClient(VectorDBBase):
}
self.batch_client.hset(self._item_key(collection_name, item['id']), mapping)
log.debug('Inserted %s items into collection %s', len(items), collection_name)
log.debug(f'Inserted {len(items)} items into collection {collection_name}')
def upsert(self, collection_name: str, items: list[VectorItem]):
self.insert(collection_name, items)
@ -591,8 +588,8 @@ class ValkeyClient(VectorDBBase):
ids.append(_decode(fields.get(b'id', b'')))
documents.append(_decode(fields.get(b'text', b'')))
try:
metadatas.append(JSONCodec.loads(_decode(fields.get(b'metadata_json', b'{}'))))
except (ValueError, TypeError):
metadatas.append(json.loads(_decode(fields.get(b'metadata_json', b'{}'))))
except (json.JSONDecodeError, TypeError):
metadatas.append({})
if limit is not None and limit > 0 and len(ids) >= limit:
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
@ -659,7 +656,7 @@ class ValkeyClient(VectorDBBase):
collections.append(name[len(idx_prefix) :])
try:
glide_ft.dropindex(self.client, idx)
log.info('Dropped index: %s', name)
log.info(f'Dropped index: {name}')
except Exception as e:
log.error(f'Error dropping index {name}: {e}')
except Exception as e:
@ -667,7 +664,7 @@ class ValkeyClient(VectorDBBase):
for collection in collections:
self._delete_keys_by_prefix(self._key_prefix(collection))
log.info('Valkey vector store reset complete (prefix: %s)', self.collection_prefix)
log.info(f'Valkey vector store reset complete (prefix: {self.collection_prefix})')
def _delete_keys_by_prefix(self, prefix: str) -> None:
cursor = '0'
@ -737,8 +734,8 @@ class ValkeyClient(VectorDBBase):
ids.append(_decode(fields.get(b'id', b'')))
documents.append(_decode(fields.get(b'text', b'')))
try:
metadatas.append(JSONCodec.loads(_decode(fields.get(b'metadata_json', b'{}'))))
except (ValueError, TypeError):
metadatas.append(json.loads(_decode(fields.get(b'metadata_json', b'{}'))))
except (json.JSONDecodeError, TypeError):
metadatas.append({})
if include_score:

View file

@ -23,7 +23,7 @@ from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
from open_webui.retrieval.vector.utils import process_metadata
def _convert_uuids_to_strings(obj: Any) -> Any:
@ -54,20 +54,6 @@ 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
@ -182,7 +168,6 @@ 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 = (
[],
@ -196,7 +181,6 @@ 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),
)

View file

@ -63,18 +63,6 @@ class VectorDBBase(ABC):
"""Search for similar vectors in a collection."""
pass
def hybrid_search(
self,
collection_name: str,
query: str,
vectors: List[List[Union[float, int]]],
filter: Optional[Dict] = None,
limit: int = 10,
hybrid_bm25_weight: float = 0.5,
) -> Optional[SearchResult]:
"""Search using a backend-native hybrid keyword/vector implementation when available."""
return None
@abstractmethod
def query(self, collection_name: str, filter: Dict, limit: Optional[int] = None) -> Optional[GetResult]:
"""Query vectors from a collection using metadata filter."""

View file

@ -1,38 +1,14 @@
import datetime as dt
from typing import Any
from datetime import datetime
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',
'documents',
'keyValuePairs',
'styles',
'languages',
]
KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures']
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
# Removes large/redundant fields from metadata dict.
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
metadata = {key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE}
return metadata
def process_metadata(
@ -45,104 +21,9 @@ def process_metadata(
# Skip large fields
if key in KEYS_TO_EXCLUDE:
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)):
if isinstance(value, (datetime, list, dict)):
result[key] = sanitize_text_for_db(str(value))
else:
result[key] = sanitize_text_for_db(value)
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]],
num_queries: int,
limit: int,
hybrid_bm25_weight: float,
) -> SearchResult:
rank_constant = 60.0
bm25_weight = min(max(hybrid_bm25_weight, 0.0), 1.0)
vector_weight = 1.0 - bm25_weight
ids = [[] for _ in range(num_queries)]
distances = [[] for _ in range(num_queries)]
documents = [[] for _ in range(num_queries)]
metadatas = [[] for _ in range(num_queries)]
for qid in range(num_queries):
candidates: dict[str, dict[str, Any]] = {}
if vector_result and vector_result.ids and qid < len(vector_result.ids):
for rank, item_id in enumerate(vector_result.ids[qid] or [], start=1):
score = vector_weight / (rank_constant + rank) if vector_weight > 0 else 0
if score <= 0:
continue
candidate = candidates.setdefault(
item_id,
{
'score': 0.0,
'document': vector_result.documents[qid][rank - 1],
'metadata': vector_result.metadatas[qid][rank - 1],
},
)
candidate['score'] += score
for rank, row in enumerate(fts_results, start=1):
score = bm25_weight / (rank_constant + rank) if bm25_weight > 0 else 0
if score <= 0:
continue
item_id = row['id']
candidate = candidates.setdefault(
item_id,
{
'score': 0.0,
'document': row['text'],
'metadata': row['vmetadata'],
},
)
candidate['score'] += score
ranked = sorted(candidates.items(), key=lambda item: item[1]['score'], reverse=True)[:limit]
ids[qid] = [item_id for item_id, _ in ranked]
distances[qid] = [candidate['score'] for _, candidate in ranked]
documents[qid] = [candidate['document'] for _, candidate in ranked]
metadatas[qid] = [candidate['metadata'] for _, candidate in ranked]
return SearchResult(ids=ids, distances=distances, documents=documents, metadatas=metadatas)

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