mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-13 01:02:25 -06:00
@@ -16,6 +16,9 @@ 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 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='*'
|
||||
|
||||
|
||||
@@ -75,6 +75,17 @@ 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:
|
||||
@@ -115,6 +126,7 @@ jobs:
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: ${{ runner.temp }}/Dockerfile
|
||||
push: true
|
||||
platforms: ${{ matrix.platform.arch }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -43,6 +43,8 @@ jobs:
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
# ── Vitest unit tests ────────────────────────────────────────────────────
|
||||
unit-tests:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Issue Labeler
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
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 title = issue.title ?? '';
|
||||
const body = issue.body ?? '';
|
||||
|
||||
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
|
||||
const bugLikeTitle = /^\s*\[?(bug|issue|fix)\]?\s*[:/\-]/i.test(title);
|
||||
|
||||
// API/CLI-created issues that reproduce the bug report form structure.
|
||||
// Only headings distinctive to the bug form (both are required fields there) —
|
||||
// generic headings like "Expected Behavior" also appear in freeform feature requests.
|
||||
const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body);
|
||||
|
||||
if (bugLikeTitle || bugFormBody) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['bug']
|
||||
});
|
||||
}
|
||||
+290
@@ -5,6 +5,296 @@ 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.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
|
||||
|
||||
+11
@@ -184,6 +184,17 @@ 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
|
||||
|
||||
@@ -93,11 +93,9 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
|
||||
|
||||
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 Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): 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.
|
||||
- 💻 **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.
|
||||
|
||||
- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 💻 **cptr** ([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.
|
||||
|
||||
|
||||
@@ -853,6 +853,13 @@ ONEDRIVE_SHAREPOINT_TENANT_ID = os.getenv('ONEDRIVE_SHAREPOINT_TENANT_ID', '')
|
||||
# RAG Content Extraction
|
||||
CONTENT_EXTRACTION_ENGINE = os.getenv('CONTENT_EXTRACTION_ENGINE', '').lower()
|
||||
|
||||
content_extraction_supported_media_mime_types = os.getenv('CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES')
|
||||
CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES = (
|
||||
[mime_type.strip() for mime_type in content_extraction_supported_media_mime_types.split(',') if mime_type.strip()]
|
||||
if content_extraction_supported_media_mime_types is not None
|
||||
else None
|
||||
)
|
||||
|
||||
DATALAB_MARKER_API_KEY = os.getenv('DATALAB_MARKER_API_KEY', '')
|
||||
|
||||
DATALAB_MARKER_API_BASE_URL = os.getenv('DATALAB_MARKER_API_BASE_URL', '')
|
||||
@@ -1176,6 +1183,7 @@ WEB_SEARCH_TRUST_ENV = os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'tru
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = os.getenv('OLLAMA_CLOUD_API_KEY', '')
|
||||
|
||||
SEARXNG_QUERY_URL = os.getenv('SEARXNG_QUERY_URL', '')
|
||||
OPENSERP_BASE_URL = os.getenv('OPENSERP_BASE_URL', 'http://localhost:7000')
|
||||
|
||||
SEARXNG_LANGUAGE = os.getenv('SEARXNG_LANGUAGE', 'all')
|
||||
|
||||
@@ -1664,7 +1672,13 @@ if default_prompt_suggestions == []:
|
||||
|
||||
DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions
|
||||
|
||||
MODEL_ORDER_LIST = []
|
||||
try:
|
||||
model_order_list = json.loads(os.getenv('MODEL_ORDER_LIST', '[]'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading MODEL_ORDER_LIST: {e}')
|
||||
model_order_list = []
|
||||
|
||||
MODEL_ORDER_LIST = model_order_list
|
||||
|
||||
try:
|
||||
default_model_metadata = json.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}'))
|
||||
@@ -1806,6 +1820,9 @@ USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
|
||||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
|
||||
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
|
||||
)
|
||||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS = (
|
||||
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS', 'True').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
USER_PERMISSIONS_CHAT_CONTROLS = os.getenv('USER_PERMISSIONS_CHAT_CONTROLS', 'True').lower() == 'true'
|
||||
@@ -1840,6 +1857,10 @@ USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = (
|
||||
os.getenv('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING = (
|
||||
os.getenv('USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING', 'False').lower() == 'true'
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_EXPORT = os.getenv('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true'
|
||||
|
||||
USER_PERMISSIONS_CHAT_IMPORT = os.getenv('USER_PERMISSIONS_CHAT_IMPORT', 'True').lower() == 'true'
|
||||
@@ -1926,10 +1947,12 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
||||
'folders': USER_PERMISSIONS_FOLDERS_ALLOW_SHARING,
|
||||
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
|
||||
'open_chats': USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING,
|
||||
'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING,
|
||||
},
|
||||
'access_grants': {
|
||||
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
|
||||
'allow_groups': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS,
|
||||
},
|
||||
'chat': {
|
||||
'controls': USER_PERMISSIONS_CHAT_CONTROLS,
|
||||
@@ -1983,10 +2006,20 @@ FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '')
|
||||
|
||||
ENABLE_CHANNELS = os.getenv('ENABLE_CHANNELS', 'False').lower() == 'true'
|
||||
|
||||
CHANNEL_MODEL_RESPONSE_MODE = os.getenv('CHANNEL_MODEL_RESPONSE_MODE', 'thread')
|
||||
|
||||
ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_AUTOMATIONS = os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_SUBAGENTS = os.getenv('ENABLE_SUBAGENTS', 'False').lower() == 'true'
|
||||
SUBAGENTS_BACKGROUND_ENABLED = os.getenv('SUBAGENTS_BACKGROUND_ENABLED', 'False').lower() == 'true'
|
||||
SUBAGENTS_MAX_CONCURRENT = int(os.getenv('SUBAGENTS_MAX_CONCURRENT', '20'))
|
||||
SUBAGENTS_MAX_ASYNC = int(os.getenv('SUBAGENTS_MAX_ASYNC', '20'))
|
||||
SUBAGENTS_MAX_ITERATIONS = int(os.getenv('SUBAGENTS_MAX_ITERATIONS', '30'))
|
||||
SUBAGENTS_MAX_OUTPUT = int(os.getenv('SUBAGENTS_MAX_OUTPUT', '30000'))
|
||||
SUBAGENTS_SYSTEM_PROMPT = os.getenv('SUBAGENTS_SYSTEM_PROMPT', '')
|
||||
|
||||
AUTOMATION_MAX_COUNT = os.getenv('AUTOMATION_MAX_COUNT', '')
|
||||
|
||||
AUTOMATION_MIN_INTERVAL = os.getenv('AUTOMATION_MIN_INTERVAL', '')
|
||||
@@ -2124,33 +2157,41 @@ TASK_MODEL = os.getenv('TASK_MODEL', '')
|
||||
|
||||
TASK_MODEL_EXTERNAL = os.getenv('TASK_MODEL_EXTERNAL', '')
|
||||
|
||||
CONTEXT_COMPACTION_MODEL = os.getenv('CONTEXT_COMPACTION_MODEL', '')
|
||||
|
||||
ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lower() == 'true'
|
||||
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
|
||||
|
||||
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
|
||||
CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None
|
||||
|
||||
CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min(
|
||||
50, max(10, int(os.getenv('CONTEXT_COMPACTION_RETENTION_PERCENTAGE', '40')))
|
||||
)
|
||||
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '')
|
||||
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '')
|
||||
|
||||
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task:
|
||||
Generate a concise, 3-5 word title with an emoji summarizing the chat history.
|
||||
Generate a concise title summarizing the chat history.
|
||||
### Guidelines:
|
||||
- The title should clearly represent the main theme or subject of the conversation.
|
||||
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
|
||||
- Keep it short: 2-4 words is best.
|
||||
- Do not use emojis, quotation marks, or special formatting.
|
||||
- Write the title in the chat's primary language; default to English if multilingual.
|
||||
- Prioritize accuracy over excessive creativity; keep it clear and simple.
|
||||
- Prioritize accuracy over creativity.
|
||||
- Your entire response must consist solely of the JSON object, without any introductory or concluding text.
|
||||
- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text.
|
||||
- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure.
|
||||
### Output:
|
||||
JSON format: { "title": "your concise title here" }
|
||||
### Examples:
|
||||
- { "title": "📉 Stock Market Trends" },
|
||||
- { "title": "🍪 Perfect Chocolate Chip Recipe" },
|
||||
- { "title": "Evolution of Music Streaming" },
|
||||
- { "title": "Remote Work Productivity Tips" },
|
||||
- { "title": "Artificial Intelligence in Healthcare" },
|
||||
- { "title": "🎮 Video Game Development Insights" }
|
||||
- { "title": "Stock Trends" },
|
||||
- { "title": "Chocolate Chip Cookies" },
|
||||
- { "title": "Music Streaming" },
|
||||
- { "title": "Remote Work" }
|
||||
### Chat History:
|
||||
<chat_history>
|
||||
{{MESSAGES:END:2}}
|
||||
@@ -2400,6 +2441,11 @@ if JWT_EXPIRES_IN == '-1':
|
||||
# OAuth config
|
||||
####################################
|
||||
|
||||
# Master switch for OAuth/OIDC sign-in. Defaults to enabled so existing
|
||||
# deployments that already have a provider configured keep working; admins can
|
||||
# turn it off to disable OAuth login without clearing their provider settings.
|
||||
ENABLE_OAUTH = os.getenv('ENABLE_OAUTH', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_OAUTH_SIGNUP = os.getenv('ENABLE_OAUTH_SIGNUP', 'False').lower() == 'true'
|
||||
|
||||
OAUTH_AUTO_REDIRECT = os.getenv('OAUTH_AUTO_REDIRECT', 'False').lower() == 'true'
|
||||
@@ -2553,6 +2599,23 @@ if _oauth_authorize_params:
|
||||
log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
|
||||
|
||||
|
||||
def oauth_client_kwargs(scope: str, **kwargs):
|
||||
client_kwargs = {
|
||||
'scope': scope,
|
||||
**kwargs,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
}
|
||||
|
||||
if OAUTH_CODE_CHALLENGE_METHOD == 'S256':
|
||||
client_kwargs['code_challenge_method'] = 'S256'
|
||||
elif OAUTH_CODE_CHALLENGE_METHOD:
|
||||
raise Exception(
|
||||
'Code challenge methods other than "%s" not supported. Given: "%s"' % ('S256', OAUTH_CODE_CHALLENGE_METHOD)
|
||||
)
|
||||
|
||||
return client_kwargs
|
||||
|
||||
|
||||
def load_oauth_providers():
|
||||
OAUTH_PROVIDERS.clear()
|
||||
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
|
||||
@@ -2563,10 +2626,7 @@ def load_oauth_providers():
|
||||
client_id=GOOGLE_CLIENT_ID,
|
||||
client_secret=GOOGLE_CLIENT_SECRET,
|
||||
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
||||
client_kwargs={
|
||||
'scope': GOOGLE_OAUTH_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(GOOGLE_OAUTH_SCOPE),
|
||||
redirect_uri=GOOGLE_REDIRECT_URI,
|
||||
**({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}),
|
||||
)
|
||||
@@ -2584,10 +2644,7 @@ def load_oauth_providers():
|
||||
client_id=MICROSOFT_CLIENT_ID,
|
||||
client_secret=MICROSOFT_CLIENT_SECRET,
|
||||
server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL}/{MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID}',
|
||||
client_kwargs={
|
||||
'scope': MICROSOFT_OAUTH_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(MICROSOFT_OAUTH_SCOPE),
|
||||
redirect_uri=MICROSOFT_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
@@ -2608,10 +2665,7 @@ def load_oauth_providers():
|
||||
authorize_url='https://github.com/login/oauth/authorize',
|
||||
api_base_url='https://api.github.com',
|
||||
userinfo_endpoint='https://api.github.com/user',
|
||||
client_kwargs={
|
||||
'scope': GITHUB_CLIENT_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(GITHUB_CLIENT_SCOPE),
|
||||
redirect_uri=GITHUB_CLIENT_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
@@ -2624,30 +2678,19 @@ def load_oauth_providers():
|
||||
if OAUTH_CLIENT_ID and (OAUTH_CLIENT_SECRET or OAUTH_CODE_CHALLENGE_METHOD) and OPENID_PROVIDER_URL:
|
||||
|
||||
def oidc_oauth_register(oauth: OAuth):
|
||||
client_kwargs = {
|
||||
'scope': OAUTH_SCOPES,
|
||||
**(
|
||||
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
|
||||
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
|
||||
else {}
|
||||
),
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
}
|
||||
|
||||
if OAUTH_CODE_CHALLENGE_METHOD and OAUTH_CODE_CHALLENGE_METHOD == 'S256':
|
||||
client_kwargs['code_challenge_method'] = 'S256'
|
||||
elif OAUTH_CODE_CHALLENGE_METHOD:
|
||||
raise Exception(
|
||||
'Code challenge methods other than "%s" not supported. Given: "%s"'
|
||||
% ('S256', OAUTH_CODE_CHALLENGE_METHOD)
|
||||
)
|
||||
|
||||
client = oauth.register(
|
||||
name='oidc',
|
||||
client_id=OAUTH_CLIENT_ID,
|
||||
client_secret=OAUTH_CLIENT_SECRET,
|
||||
server_metadata_url=OPENID_PROVIDER_URL,
|
||||
client_kwargs=client_kwargs,
|
||||
client_kwargs=oauth_client_kwargs(
|
||||
OAUTH_SCOPES,
|
||||
**(
|
||||
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
|
||||
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
|
||||
else {}
|
||||
),
|
||||
),
|
||||
redirect_uri=OPENID_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
@@ -2783,6 +2826,7 @@ DEFAULT_CONFIG = {
|
||||
'onedrive.sharepoint_url': ONEDRIVE_SHAREPOINT_URL,
|
||||
'onedrive.sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
'rag.content_extraction_engine': CONTENT_EXTRACTION_ENGINE,
|
||||
'rag.content_extraction.supported_media_mime_types': CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
|
||||
'rag.datalab_marker_api_key': DATALAB_MARKER_API_KEY,
|
||||
'rag.datalab_marker_api_base_url': DATALAB_MARKER_API_BASE_URL,
|
||||
'rag.datalab_marker_additional_config': DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
@@ -2875,6 +2919,7 @@ DEFAULT_CONFIG = {
|
||||
'web.search.trust_env': WEB_SEARCH_TRUST_ENV,
|
||||
'web.search.ollama_cloud_api_key': OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
'web.search.searxng_query_url': SEARXNG_QUERY_URL,
|
||||
'web.search.openserp_base_url': OPENSERP_BASE_URL,
|
||||
'web.search.searxng_language': SEARXNG_LANGUAGE,
|
||||
'web.search.yacy_query_url': YACY_QUERY_URL,
|
||||
'web.search.yacy_username': YACY_USERNAME,
|
||||
@@ -3014,8 +3059,16 @@ DEFAULT_CONFIG = {
|
||||
'folders.enable': ENABLE_FOLDERS,
|
||||
'folders.max_file_count': FOLDER_MAX_FILE_COUNT,
|
||||
'channels.enable': ENABLE_CHANNELS,
|
||||
'channels.model_response_mode': CHANNEL_MODEL_RESPONSE_MODE,
|
||||
'calendar.enable': ENABLE_CALENDAR,
|
||||
'automations.enable': ENABLE_AUTOMATIONS,
|
||||
'subagents.enable': ENABLE_SUBAGENTS,
|
||||
'subagents.background_enabled': SUBAGENTS_BACKGROUND_ENABLED,
|
||||
'subagents.max_concurrent': SUBAGENTS_MAX_CONCURRENT,
|
||||
'subagents.max_async': SUBAGENTS_MAX_ASYNC,
|
||||
'subagents.max_iterations': SUBAGENTS_MAX_ITERATIONS,
|
||||
'subagents.max_output': SUBAGENTS_MAX_OUTPUT,
|
||||
'subagents.system_prompt': SUBAGENTS_SYSTEM_PROMPT,
|
||||
'automations.max_count': AUTOMATION_MAX_COUNT,
|
||||
'automations.min_interval': AUTOMATION_MIN_INTERVAL,
|
||||
'automations.auth_token_expires_in': AUTOMATION_AUTH_TOKEN_EXPIRES_IN,
|
||||
@@ -3032,8 +3085,11 @@ DEFAULT_CONFIG = {
|
||||
'auth.admin.email': ADMIN_EMAIL,
|
||||
'task.model.default': TASK_MODEL,
|
||||
'task.model.external': TASK_MODEL_EXTERNAL,
|
||||
'chat.context_compaction.model': CONTEXT_COMPACTION_MODEL,
|
||||
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
|
||||
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
|
||||
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
|
||||
'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE,
|
||||
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
|
||||
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
@@ -3055,6 +3111,7 @@ DEFAULT_CONFIG = {
|
||||
'auth.api_key.endpoint_restrictions': ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
||||
'auth.api_key.allowed_endpoints': API_KEYS_ALLOWED_ENDPOINTS,
|
||||
'auth.jwt_expiry': JWT_EXPIRES_IN,
|
||||
'oauth.enable': ENABLE_OAUTH,
|
||||
'oauth.enable_signup': ENABLE_OAUTH_SIGNUP,
|
||||
'oauth.auto_redirect': OAUTH_AUTO_REDIRECT,
|
||||
'oauth.refresh_token.include_scope': OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,
|
||||
|
||||
@@ -102,6 +102,7 @@ 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():
|
||||
@@ -149,6 +150,11 @@ 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):
|
||||
@@ -389,6 +395,12 @@ 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
|
||||
@@ -565,12 +577,28 @@ try:
|
||||
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()
|
||||
|
||||
# When False (default), outbound HTTP requests do not follow 3xx redirects.
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.getenv('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true'
|
||||
|
||||
@@ -594,6 +622,15 @@ 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.
|
||||
@@ -788,6 +825,18 @@ 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
|
||||
@@ -1034,10 +1083,34 @@ 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'
|
||||
)
|
||||
@@ -1091,15 +1164,19 @@ except ValueError:
|
||||
MAX_BODY_LOG_SIZE = 2048
|
||||
|
||||
# Comma separated list for urls to exclude from audit
|
||||
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]
|
||||
AUDIT_EXCLUDED_PATHS = [
|
||||
path
|
||||
for path in (
|
||||
path.strip().lstrip('/') for path in os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
|
||||
)
|
||||
if path
|
||||
]
|
||||
|
||||
# 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 = 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]
|
||||
AUDIT_INCLUDED_PATHS = [
|
||||
path for path in (path.strip().lstrip('/') for path in os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')) 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'
|
||||
|
||||
@@ -8,7 +8,7 @@ import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from open_webui.env import VERSION
|
||||
from open_webui.env import ENABLE_PLUGINS, VERSION
|
||||
from open_webui.models.config import Config
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
@@ -161,6 +161,12 @@ class EventDefinitions(BaseModel):
|
||||
CHAT_CREATED: EventDefinition = EventDefinition(
|
||||
name='chat.created', description='A chat was created.', message='Chat created'
|
||||
)
|
||||
CHAT_FINISHED: EventDefinition = EventDefinition(
|
||||
name='chat.finished', description='A chat response finished.', message='Chat finished'
|
||||
)
|
||||
CHAT_FAILED: EventDefinition = EventDefinition(
|
||||
name='chat.failed', description='A chat response failed.', message='Chat failed'
|
||||
)
|
||||
CHAT_IMPORTED: EventDefinition = EventDefinition(
|
||||
name='chat.imported', description='A chat was imported.', message='Chat imported'
|
||||
)
|
||||
@@ -258,6 +264,11 @@ class EventDefinitions(BaseModel):
|
||||
description='A channel member active state was updated.',
|
||||
message='Channel member active updated',
|
||||
)
|
||||
CHANNEL_MESSAGE: EventDefinition = EventDefinition(
|
||||
name='channel.message',
|
||||
description='A channel message was posted.',
|
||||
message='Channel message',
|
||||
)
|
||||
CHANNEL_WEBHOOK_CREATED: EventDefinition = EventDefinition(
|
||||
name='channel.webhook.created',
|
||||
description='A channel incoming webhook was created.',
|
||||
@@ -472,6 +483,16 @@ class EventDefinitions(BaseModel):
|
||||
FUNCTION_DISABLED: EventDefinition = EventDefinition(
|
||||
name='function.disabled', description='A function was disabled.', message='Function disabled'
|
||||
)
|
||||
FUNCTION_ENABLE_STARTED: EventDefinition = EventDefinition(
|
||||
name='function.enable_started',
|
||||
description='A function is about to be enabled.',
|
||||
message='Function enable started',
|
||||
)
|
||||
FUNCTION_DISABLE_STARTED: EventDefinition = EventDefinition(
|
||||
name='function.disable_started',
|
||||
description='A function is about to be disabled.',
|
||||
message='Function disable started',
|
||||
)
|
||||
FUNCTION_VALVES_UPDATED: EventDefinition = EventDefinition(
|
||||
name='function.valves_updated', description='Function valves were updated.', message='Function valves updated'
|
||||
)
|
||||
@@ -566,6 +587,11 @@ class EventDefinitions(BaseModel):
|
||||
description='A calendar event RSVP was updated.',
|
||||
message='Calendar Event rsvp updated',
|
||||
)
|
||||
CALENDAR_ALERT: EventDefinition = EventDefinition(
|
||||
name='calendar.alert',
|
||||
description='A calendar event alert was triggered.',
|
||||
message='Calendar alert',
|
||||
)
|
||||
AUTOMATION_CREATED: EventDefinition = EventDefinition(
|
||||
name='automation.created', description='An automation was created.', message='Automation created'
|
||||
)
|
||||
@@ -622,6 +648,12 @@ class EventDefinitions(BaseModel):
|
||||
TERMINAL_SESSION_CLOSED: EventDefinition = EventDefinition(
|
||||
name='terminal.session.closed', description='A terminal session was closed.', message='Terminal Session closed'
|
||||
)
|
||||
NOTIFICATION_TEST: EventDefinition = EventDefinition(
|
||||
name='notification.test', description='A notification target test was sent.', message='Notification test'
|
||||
)
|
||||
NOTIFICATION_MANUAL: EventDefinition = EventDefinition(
|
||||
name='notification.manual', description='A manual notification was sent.', message='Notification sent'
|
||||
)
|
||||
|
||||
|
||||
EVENTS = EventDefinitions()
|
||||
@@ -629,6 +661,12 @@ EVENT_DEFINITIONS = tuple(getattr(EVENTS, field_name) for field_name in EventDef
|
||||
EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS}
|
||||
EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS)
|
||||
EVENT_CATALOG_SET = set(EVENT_CATALOG)
|
||||
NOTIFICATION_EVENTS = (
|
||||
EVENTS.CHAT_FINISHED.name,
|
||||
EVENTS.CHAT_FAILED.name,
|
||||
EVENTS.CHANNEL_MESSAGE.name,
|
||||
EVENTS.CALENDAR_ALERT.name,
|
||||
)
|
||||
|
||||
|
||||
def get_event_catalog() -> list[dict[str, str]]:
|
||||
@@ -1024,7 +1062,27 @@ class WebhookEventSink:
|
||||
schedule_webhook_dispatch(app, event)
|
||||
|
||||
|
||||
async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None:
|
||||
def schedule_notification_dispatch(app: Any, event: Event) -> None:
|
||||
try:
|
||||
from open_webui.utils.notifications import dispatch_notification_event
|
||||
|
||||
asyncio.create_task(dispatch_notification_event(app, event))
|
||||
except RuntimeError:
|
||||
log.exception('Notification delivery could not be scheduled for %s', event.event)
|
||||
|
||||
|
||||
class NotificationEventSink:
|
||||
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
|
||||
if event.event in NOTIFICATION_EVENTS:
|
||||
schedule_notification_dispatch(app, event)
|
||||
|
||||
|
||||
async def dispatch_event_functions(
|
||||
app: Any, event: Event, request: Any | None = None, extra_function_ids: list[str] | None = None
|
||||
) -> None:
|
||||
if not ENABLE_PLUGINS:
|
||||
return
|
||||
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
@@ -1033,6 +1091,12 @@ async def dispatch_event_functions(app: Any, event: Event, request: Any | None =
|
||||
|
||||
try:
|
||||
event_functions = await Functions.get_functions_by_type('event', active_only=True)
|
||||
if extra_function_ids:
|
||||
extra_functions = await Functions.get_functions_by_ids(extra_function_ids)
|
||||
existing_ids = {function.id for function in event_functions}
|
||||
event_functions.extend(
|
||||
function for function in extra_functions if function.type == 'event' and function.id not in existing_ids
|
||||
)
|
||||
except Exception:
|
||||
log.exception('Event functions could not be loaded for %s', event.event)
|
||||
return
|
||||
@@ -1081,7 +1145,7 @@ class EventFunctionSink:
|
||||
schedule_event_function_dispatch(app, event, request)
|
||||
|
||||
|
||||
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()]
|
||||
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
|
||||
|
||||
|
||||
async def publish_event(
|
||||
@@ -1147,6 +1211,20 @@ async def publish_model_provider_request_failed(
|
||||
else 'upstream_error'
|
||||
)
|
||||
|
||||
# Server-log only; the upstream error body is otherwise invisible to admins
|
||||
# (event sinks require an event function or webhook to be configured).
|
||||
log.log(
|
||||
logging.ERROR if status >= 500 else logging.WARNING,
|
||||
'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s',
|
||||
provider,
|
||||
status,
|
||||
error_type,
|
||||
base_url,
|
||||
requested_model or '-',
|
||||
error_code or '-',
|
||||
error_text[:MAX_STRING_LENGTH] or '-',
|
||||
)
|
||||
|
||||
data = {
|
||||
'error_type': error_type,
|
||||
'status': status,
|
||||
|
||||
@@ -20,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, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
@@ -69,6 +69,9 @@ 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 = []
|
||||
|
||||
@@ -144,7 +147,10 @@ async def get_function_models(request):
|
||||
return pipe_models
|
||||
|
||||
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
|
||||
if models is None:
|
||||
models = {}
|
||||
|
||||
async def execute_pipe(pipe, params):
|
||||
if inspect.iscoroutinefunction(pipe):
|
||||
return await pipe(**params)
|
||||
@@ -203,6 +209,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
||||
|
||||
return params
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -370,6 +370,8 @@ 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_SQLALCHEMY_DATABASE_URL,
|
||||
@@ -377,7 +379,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
||||
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')
|
||||
|
||||
+349
-93
@@ -83,6 +83,7 @@ from open_webui.env import (
|
||||
ENABLE_COMPRESSION_MIDDLEWARE,
|
||||
ENABLE_CUSTOM_MODEL_FALLBACK,
|
||||
ENABLE_EASTER_EGGS,
|
||||
ENABLE_PLUGINS,
|
||||
EXTERNAL_PWA_MANIFEST_URL,
|
||||
# OAuth Back-Channel Logout
|
||||
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
|
||||
@@ -153,6 +154,7 @@ from open_webui.routers import (
|
||||
knowledge,
|
||||
memories,
|
||||
models,
|
||||
notifications,
|
||||
notes,
|
||||
ollama,
|
||||
openai,
|
||||
@@ -218,7 +220,16 @@ from open_webui.utils.chat import (
|
||||
from open_webui.utils.chat import (
|
||||
generate_chat_completion as chat_completion_handler,
|
||||
)
|
||||
from open_webui.utils.chat_id import (
|
||||
get_temporary_chat_session_id,
|
||||
is_saved_chat_id,
|
||||
is_temporary_chat_id,
|
||||
)
|
||||
from open_webui.utils.chat_variables import (
|
||||
normalize_chat_variables,
|
||||
)
|
||||
from open_webui.utils.embeddings import generate_embeddings
|
||||
from open_webui.utils.json_response import apply_orjson_http_json
|
||||
from open_webui.utils.logger import start_logger
|
||||
from open_webui.utils.middleware import (
|
||||
background_tasks_handler,
|
||||
@@ -226,6 +237,7 @@ from open_webui.utils.middleware import (
|
||||
process_chat_payload,
|
||||
process_chat_response,
|
||||
)
|
||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||
from open_webui.utils.models import (
|
||||
check_model_access,
|
||||
get_all_base_models,
|
||||
@@ -247,7 +259,7 @@ from open_webui.utils.oauth import (
|
||||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.redis import get_redis_client
|
||||
from open_webui.utils.security_headers import SecurityHeadersMiddleware
|
||||
from open_webui.utils.session_pool import get_session
|
||||
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
|
||||
from open_webui.utils.tools import set_terminal_servers, set_tool_servers
|
||||
|
||||
if SAFE_MODE:
|
||||
@@ -258,6 +270,16 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def emit_chat_list_event(metadata: dict, chat_id: str):
|
||||
if not is_saved_chat_id(chat_id):
|
||||
return
|
||||
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, metadata.get('user_id'))
|
||||
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id, 'folder_id': folder_id}})
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
@@ -319,8 +341,9 @@ async def lifespan(app: FastAPI):
|
||||
await migrate_legacy_webhook_config()
|
||||
await publish_event(app, EVENTS.SYSTEM_STARTUP_STARTED, source='system')
|
||||
|
||||
license_task = None
|
||||
if LICENSE_KEY:
|
||||
get_license_data(app, LICENSE_KEY)
|
||||
license_task = asyncio.create_task(asyncio.to_thread(get_license_data, app, LICENSE_KEY))
|
||||
|
||||
# Create admin account from env vars if specified and no users exist
|
||||
if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD:
|
||||
@@ -408,6 +431,14 @@ async def lifespan(app: FastAPI):
|
||||
log.warning(f'Failed to initialize terminal servers at startup: {e}')
|
||||
|
||||
# Mark application as ready to accept traffic from a startup perspective.
|
||||
if license_task:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(license_task), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning('License data retrieval is still pending; continuing startup without it')
|
||||
except Exception as e:
|
||||
log.warning(f'License data retrieval failed during startup: {e}')
|
||||
|
||||
app.state.startup_complete = True
|
||||
await publish_event(app, EVENTS.SYSTEM_STARTUP_COMPLETED, source='system')
|
||||
|
||||
@@ -426,6 +457,10 @@ async def lifespan(app: FastAPI):
|
||||
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system')
|
||||
|
||||
|
||||
# Opt-in (ENABLE_ORJSON): orjson for request-body parsing and JSONResponse bodies;
|
||||
# response_model routes keep FastAPI's Pydantic fast path either way.
|
||||
apply_orjson_http_json()
|
||||
|
||||
app = FastAPI(
|
||||
title='Open WebUI',
|
||||
docs_url='/docs' if ENV == 'dev' else None,
|
||||
@@ -697,6 +732,25 @@ app.state.speech_speaker_embeddings_dataset = None
|
||||
app.state.MODELS = MODELS
|
||||
|
||||
# Add the middleware to the app
|
||||
try:
|
||||
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
|
||||
except ValueError as e:
|
||||
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
|
||||
audit_level = AuditLevel.NONE
|
||||
|
||||
# Added before CompressMiddleware so audit sits inside compression and
|
||||
# captures response bodies before they are compressed (last added runs
|
||||
# outermost).
|
||||
if audit_level != AuditLevel.NONE:
|
||||
app.add_middleware(
|
||||
AuditLoggingMiddleware,
|
||||
audit_level=audit_level,
|
||||
excluded_paths=AUDIT_EXCLUDED_PATHS,
|
||||
included_paths=AUDIT_INCLUDED_PATHS,
|
||||
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
|
||||
max_body_size=MAX_BODY_LOG_SIZE,
|
||||
)
|
||||
|
||||
if ENABLE_COMPRESSION_MIDDLEWARE:
|
||||
app.add_middleware(CompressMiddleware)
|
||||
|
||||
@@ -751,6 +805,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes'])
|
||||
|
||||
|
||||
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
|
||||
app.include_router(notifications.router, prefix='/api/v1/notifications', tags=['notifications'])
|
||||
app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge'])
|
||||
app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts'])
|
||||
app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools'])
|
||||
@@ -774,21 +829,6 @@ if ENABLE_SCIM:
|
||||
app.include_router(scim.router, prefix='/api/v1/scim/v2', tags=['scim'])
|
||||
|
||||
|
||||
try:
|
||||
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
|
||||
except ValueError as e:
|
||||
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
|
||||
audit_level = AuditLevel.NONE
|
||||
|
||||
if audit_level != AuditLevel.NONE:
|
||||
app.add_middleware(
|
||||
AuditLoggingMiddleware,
|
||||
audit_level=audit_level,
|
||||
excluded_paths=AUDIT_EXCLUDED_PATHS,
|
||||
included_paths=AUDIT_INCLUDED_PATHS,
|
||||
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
|
||||
max_body_size=MAX_BODY_LOG_SIZE,
|
||||
)
|
||||
##################################
|
||||
#
|
||||
# Chat Endpoints
|
||||
@@ -801,12 +841,20 @@ if audit_level != AuditLevel.NONE:
|
||||
async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)):
|
||||
all_models = await get_all_models(request, refresh=refresh, user=user)
|
||||
|
||||
models = []
|
||||
for model in all_models:
|
||||
# Filter out filter pipelines
|
||||
if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter':
|
||||
continue
|
||||
# Filter out filter pipelines
|
||||
models = [
|
||||
model for model in all_models if not ('pipeline' in model and model['pipeline'].get('type', None) == 'filter')
|
||||
]
|
||||
|
||||
# Chat requests resolve models by ID from request.app.state.MODELS, where
|
||||
# duplicate IDs collapse to the last model. Return the same effective list.
|
||||
models = list({model['id']: model for model in models}.values())
|
||||
|
||||
# Access-filter first so the per-model payload work below only runs for
|
||||
# models the caller can actually see.
|
||||
models = await get_filtered_models(models, user)
|
||||
|
||||
for model in models:
|
||||
# Remove profile image URL to reduce payload size
|
||||
if model.get('info', {}).get('meta', {}).get('profile_image_url'):
|
||||
model['info']['meta'].pop('profile_image_url', None)
|
||||
@@ -820,13 +868,6 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
|
||||
except Exception as e:
|
||||
log.debug(f'Error processing model tags: {e}')
|
||||
model['tags'] = []
|
||||
pass
|
||||
|
||||
models.append(model)
|
||||
|
||||
# Chat requests resolve models by ID from request.app.state.MODELS, where
|
||||
# duplicate IDs collapse to the last model. Return the same effective list.
|
||||
models = list({model['id']: model for model in models}.values())
|
||||
|
||||
model_order_list = await Config.get('ui.model_order_list')
|
||||
if model_order_list:
|
||||
@@ -839,11 +880,10 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
|
||||
)
|
||||
)
|
||||
|
||||
models = await get_filtered_models(models, user)
|
||||
|
||||
log.debug(
|
||||
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
|
||||
)
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug(
|
||||
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
|
||||
)
|
||||
return {'data': models}
|
||||
|
||||
|
||||
@@ -857,12 +897,6 @@ class ModelUnloadForm(BaseModel):
|
||||
model: str
|
||||
|
||||
|
||||
def strip_provider_model_prefix(model_id: str, prefix_id: str | None) -> str:
|
||||
if prefix_id and model_id.startswith(f'{prefix_id}.'):
|
||||
return model_id[len(f'{prefix_id}.') :]
|
||||
return model_id
|
||||
|
||||
|
||||
@app.post('/api/models/unload')
|
||||
async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)):
|
||||
"""
|
||||
@@ -1004,6 +1038,17 @@ async def embeddings(request: Request, form_data: dict, user=Depends(get_verifie
|
||||
return await generate_embeddings(request, form_data, user)
|
||||
|
||||
|
||||
async def _set_direct_model(request: Request, model_item: dict, user) -> None:
|
||||
model_meta = (model_item.get('info') or {}).get('meta') or {}
|
||||
knowledge_items = model_meta.get('knowledge')
|
||||
if knowledge_items:
|
||||
from open_webui.utils.access_control.files import get_accessible_folder_files
|
||||
|
||||
model_meta['knowledge'] = await get_accessible_folder_files(knowledge_items, user)
|
||||
request.state.direct = True
|
||||
request.state.model = model_item
|
||||
|
||||
|
||||
@app.post('/api/chat/completions')
|
||||
@app.post('/api/v1/chat/completions') # Experimental: Compatibility with OpenAI API
|
||||
async def chat_completion(
|
||||
@@ -1031,14 +1076,12 @@ async def chat_completion(
|
||||
# Check if user has access to the model
|
||||
if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
try:
|
||||
await check_model_access(user, model)
|
||||
await check_model_access(user, model, model_info=model_info)
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
model = model_item
|
||||
|
||||
request.state.direct = True
|
||||
request.state.model = model
|
||||
await _set_direct_model(request, model, user)
|
||||
|
||||
# Model params: global defaults as base, per-model overrides win
|
||||
default_model_params = await Config.get('models.default_params', {}) or {}
|
||||
@@ -1112,6 +1155,13 @@ async def chat_completion(
|
||||
message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}]
|
||||
|
||||
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
|
||||
chat_id = form_data.get('chat_id') or ''
|
||||
chat_variables = form_data.pop('chat_variables', None)
|
||||
if chat_variables is None:
|
||||
existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
|
||||
chat_variables = existing_chat.variables if existing_chat else {}
|
||||
|
||||
chat_variables = normalize_chat_variables(chat_variables)
|
||||
|
||||
# Drop tool_servers if caller lacks features.direct_tool_servers —
|
||||
# mirrors the storage-side strip in user/settings/update.
|
||||
@@ -1130,6 +1180,7 @@ async def chat_completion(
|
||||
metadata = {
|
||||
'user_id': user.id,
|
||||
'user_agent': request.headers.get('user-agent', '') or '',
|
||||
'internal': getattr(request.state, 'internal', False) is True,
|
||||
'chat_id': form_data.pop('chat_id', None) or '',
|
||||
'user_message': user_message,
|
||||
'user_message_id': user_message.get('id') if user_message else None,
|
||||
@@ -1142,6 +1193,7 @@ async def chat_completion(
|
||||
'files': form_data.get('files', None),
|
||||
'features': form_data.get('features', {}),
|
||||
'variables': form_data.get('variables', {}),
|
||||
'chat_variables': chat_variables,
|
||||
'model': model,
|
||||
'direct': model_item.get('direct', False),
|
||||
'params': {
|
||||
@@ -1205,9 +1257,7 @@ async def chat_completion(
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
if not chat_id.startswith('local:') and not chat_id.startswith(
|
||||
'channel:'
|
||||
): # temporary/channel chats are not stored
|
||||
if is_saved_chat_id(chat_id):
|
||||
if is_new_chat:
|
||||
# Build the full history upfront with ALL assistant placeholders
|
||||
user_message = metadata.get('user_message') or {}
|
||||
@@ -1224,7 +1274,7 @@ async def chat_completion(
|
||||
target_model_id = entry['model_id']
|
||||
assistant_message_id = entry['message_id']
|
||||
if assistant_message_id:
|
||||
history_messages[assistant_message_id] = {
|
||||
assistant_message = {
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
@@ -1234,6 +1284,11 @@ async def chat_completion(
|
||||
'model': target_model_id,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
# Preserve the side-by-side column index so duplicate
|
||||
# models don't collapse into one another on reload.
|
||||
if entry.get('modelIdx') is not None:
|
||||
assistant_message['modelIdx'] = entry['modelIdx']
|
||||
history_messages[assistant_message_id] = assistant_message
|
||||
|
||||
await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
@@ -1256,6 +1311,7 @@ async def chat_completion(
|
||||
'tags': [],
|
||||
'timestamp': int(time.time() * 1000),
|
||||
},
|
||||
variables=chat_variables,
|
||||
folder_id=metadata.get('folder_id'),
|
||||
),
|
||||
)
|
||||
@@ -1266,6 +1322,7 @@ async def chat_completion(
|
||||
subject_id=chat_id,
|
||||
data={'title': 'New Chat'},
|
||||
)
|
||||
await emit_chat_list_event(metadata, chat_id)
|
||||
if user_message_id:
|
||||
await publish_event(
|
||||
request,
|
||||
@@ -1358,7 +1415,9 @@ async def chat_completion(
|
||||
updated['files'] = chat_files
|
||||
if selected_chat_models:
|
||||
updated['models'] = selected_chat_models
|
||||
await Chats.update_chat_by_id(chat_id, updated)
|
||||
await Chats.update_chat_by_id(chat_id, updated, touch=False)
|
||||
|
||||
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
|
||||
|
||||
# Save user message to DB
|
||||
if user_message and user_message.get('id'):
|
||||
@@ -1367,6 +1426,7 @@ async def chat_completion(
|
||||
user_message['id'],
|
||||
user_message,
|
||||
)
|
||||
await emit_chat_list_event({**metadata, 'message_id': user_message['id']}, chat_id)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MESSAGE_CREATED,
|
||||
@@ -1378,6 +1438,15 @@ async def chat_completion(
|
||||
'content_preview': user_message.get('content', '')[:300],
|
||||
},
|
||||
)
|
||||
if not getattr(request.state, 'internal', False) and not (user_message.get('meta') or {}).get(
|
||||
'internal'
|
||||
):
|
||||
try:
|
||||
from open_webui.utils.timers import cancel_timers_for_chat
|
||||
|
||||
await cancel_timers_for_chat(chat_id, 'chat.user_message', user.id)
|
||||
except Exception:
|
||||
log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id)
|
||||
|
||||
# Link grandparent → user message (childrenIds)
|
||||
grandparent_id = user_message.get('parentId')
|
||||
@@ -1432,19 +1501,24 @@ async def chat_completion(
|
||||
target_model_id = entry['model_id']
|
||||
assistant_message_id = entry['message_id']
|
||||
if assistant_message_id:
|
||||
assistant_message = {
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': target_model_id,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
# Preserve the side-by-side column index so duplicate
|
||||
# models don't collapse into one another on reload.
|
||||
if entry.get('modelIdx') is not None:
|
||||
assistant_message['modelIdx'] = entry['modelIdx']
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
assistant_message_id,
|
||||
{
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': target_model_id,
|
||||
'timestamp': int(time.time()),
|
||||
},
|
||||
assistant_message,
|
||||
)
|
||||
await publish_event(
|
||||
request,
|
||||
@@ -1513,9 +1587,7 @@ async def chat_completion(
|
||||
if metadata.get('chat_id') and metadata.get('message_id'):
|
||||
# Update the chat message with the error
|
||||
try:
|
||||
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get(
|
||||
'chat_id', ''
|
||||
).startswith('channel:'):
|
||||
if is_saved_chat_id(metadata.get('chat_id')):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
@@ -1583,15 +1655,55 @@ async def chat_completion(
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
try:
|
||||
await asyncio.shield(event_emitter({'type': 'chat:active', 'data': {'active': False}}))
|
||||
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(
|
||||
chat_id, user.id
|
||||
)
|
||||
await asyncio.shield(
|
||||
event_emitter(
|
||||
{
|
||||
'type': 'chat:active',
|
||||
'data': {'active': False, 'folder_id': folder_id},
|
||||
}
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
chat_id = metadata.get('chat_id')
|
||||
if (
|
||||
chat_id
|
||||
and getattr(request.state, 'internal', False) is not True
|
||||
and not await has_active_tasks(request.app.state.redis, chat_id)
|
||||
):
|
||||
from open_webui.utils.subagents import process_pending_internal_messages
|
||||
|
||||
await process_pending_internal_messages(
|
||||
request,
|
||||
chat_id,
|
||||
user.id,
|
||||
{
|
||||
'model_id': metadata.get('model_id') or form_data.get('model'),
|
||||
'session_id': metadata.get('session_id'),
|
||||
'tool_ids': metadata.get('tool_ids') or [],
|
||||
'skill_ids': metadata.get('skill_ids') or [],
|
||||
'system_prompt': metadata.get('system_prompt'),
|
||||
'filter_ids': metadata.get('filter_ids') or [],
|
||||
'terminal_id': metadata.get('terminal_id'),
|
||||
'features': metadata.get('features') or {},
|
||||
'variables': metadata.get('variables') or {},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id'))
|
||||
|
||||
# Fan out: one task per model
|
||||
if metadata.get('session_id') and metadata.get('chat_id'):
|
||||
task_ids = []
|
||||
subagent_results = []
|
||||
is_internal = getattr(request.state, 'internal', False) is True
|
||||
chat_id = metadata['chat_id']
|
||||
|
||||
for idx, entry in enumerate(message_ids):
|
||||
@@ -1604,6 +1716,7 @@ async def chat_completion(
|
||||
per_model_metadata = {
|
||||
**metadata,
|
||||
'message_id': assistant_message_id,
|
||||
'task_id': str(uuid4()),
|
||||
}
|
||||
|
||||
# Per-model form_data: own model
|
||||
@@ -1618,28 +1731,39 @@ async def chat_completion(
|
||||
|
||||
# Only the first model runs chat-level background tasks;
|
||||
# subsequent models only run follow-ups.
|
||||
process = process_chat(
|
||||
request,
|
||||
model_form_data,
|
||||
user,
|
||||
per_model_metadata,
|
||||
resolved_model,
|
||||
tasks
|
||||
if idx == 0
|
||||
else {
|
||||
k: v for k, v in (tasks or {}).items() if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
|
||||
}
|
||||
or None,
|
||||
)
|
||||
if is_internal:
|
||||
subagent_results.append(await process)
|
||||
continue
|
||||
|
||||
task_id, _ = await create_task(
|
||||
request.app.state.redis,
|
||||
process_chat(
|
||||
request,
|
||||
model_form_data,
|
||||
user,
|
||||
per_model_metadata,
|
||||
resolved_model,
|
||||
tasks
|
||||
if idx == 0
|
||||
else {
|
||||
k: v
|
||||
for k, v in (tasks or {}).items()
|
||||
if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
|
||||
}
|
||||
or None,
|
||||
),
|
||||
process,
|
||||
id=chat_id,
|
||||
task_id=per_model_metadata['task_id'],
|
||||
)
|
||||
per_model_metadata['task_id'] = task_id
|
||||
task_ids.append(task_id)
|
||||
|
||||
if is_internal:
|
||||
return {
|
||||
'status': True,
|
||||
'task_ids': [],
|
||||
'chat_id': chat_id,
|
||||
'results': subagent_results,
|
||||
}
|
||||
|
||||
# Emit chat:active=true
|
||||
if task_ids:
|
||||
event_emitter = await get_event_emitter(
|
||||
@@ -1647,7 +1771,8 @@ async def chat_completion(
|
||||
update_db=False,
|
||||
)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
|
||||
folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, user.id)
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': True, 'folder_id': folder_id}})
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
@@ -1679,10 +1804,80 @@ app.state.CHAT_COMPLETION_HANDLER = chat_completion
|
||||
from open_webui.utils.anthropic import (
|
||||
convert_anthropic_to_openai_payload,
|
||||
convert_openai_to_anthropic_response,
|
||||
is_anthropic_messages_passthrough,
|
||||
openai_stream_to_anthropic_stream,
|
||||
)
|
||||
|
||||
|
||||
@app.post('/api/message/count_tokens')
|
||||
@app.post('/api/v1/messages/count_tokens') # Anthropic Messages token-count endpoint
|
||||
async def count_message_tokens(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return {'input_tokens': await openai.count_anthropic_tokens(request, form_data, user)}
|
||||
|
||||
|
||||
async def passthrough_anthropic_messages(request: Request, form_data: dict, user) -> Response | dict:
|
||||
requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_token_count_target(
|
||||
request, form_data, user
|
||||
)
|
||||
request_url = f'{url.rstrip("/")}/messages'
|
||||
response = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
session = await get_session()
|
||||
response = await session.request(
|
||||
method='POST',
|
||||
url=request_url,
|
||||
data=json.dumps(payload),
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=openai.AIOHTTP_CLIENT_TIMEOUT),
|
||||
)
|
||||
|
||||
if 'text/event-stream' in response.headers.get('Content-Type', ''):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(response),
|
||||
status_code=response.status,
|
||||
headers=openai._clean_proxy_headers(response.headers),
|
||||
)
|
||||
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
if response.status >= 400:
|
||||
await openai.publish_model_provider_request_failed(
|
||||
request,
|
||||
actor=user,
|
||||
provider='openai-compatible',
|
||||
base_url=url,
|
||||
api_key=key,
|
||||
status=response.status,
|
||||
requested_model=requested_model,
|
||||
upstream_error=response_data,
|
||||
)
|
||||
if isinstance(response_data, (dict, list)):
|
||||
return JSONResponse(status_code=response.status, content=response_data)
|
||||
return Response(status_code=response.status, content=response_data)
|
||||
|
||||
return response_data
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception('Failed to passthrough Anthropic Messages request for model %s', requested_model)
|
||||
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
finally:
|
||||
if not streaming:
|
||||
await cleanup_response(response)
|
||||
|
||||
|
||||
@app.post('/api/message')
|
||||
@app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint
|
||||
async def generate_messages(
|
||||
@@ -1703,10 +1898,40 @@ async def generate_messages(
|
||||
Authentication: Supports both standard Authorization header and
|
||||
Anthropic's x-api-key header (via middleware translation).
|
||||
"""
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
requested_model = form_data.get('model', '')
|
||||
input_tokens = None
|
||||
try:
|
||||
input_tokens = await openai.count_anthropic_tokens(request, form_data, user)
|
||||
except Exception:
|
||||
# Counting must not turn a compatible generation request into an outage.
|
||||
log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True)
|
||||
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data)
|
||||
model_id = requested_model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
if model_info and model_info.base_model_id:
|
||||
model_id = model_info.base_model_id
|
||||
|
||||
passthrough_params = []
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await openai.get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
model = models.get(model_id)
|
||||
if model:
|
||||
url, _, api_config = await openai.get_openai_connection(model['urlIdx'])
|
||||
if is_anthropic_messages_passthrough(url, api_config):
|
||||
return await passthrough_anthropic_messages(request, form_data, user)
|
||||
passthrough_params = api_config.get('passthrough_params') or []
|
||||
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
|
||||
model_meta = model_info.meta.model_dump() if model_info and model_info.meta else {}
|
||||
if (model_meta.get('capabilities') or {}).get('usage') is True:
|
||||
if openai_payload.get('stream'):
|
||||
stream_options = openai_payload.get('stream_options')
|
||||
if not isinstance(stream_options, dict):
|
||||
stream_options = {}
|
||||
openai_payload['stream_options'] = {**stream_options, 'include_usage': True}
|
||||
|
||||
# Route through the existing chat_completion handler
|
||||
response = await chat_completion(request, openai_payload, user)
|
||||
@@ -1715,7 +1940,7 @@ async def generate_messages(
|
||||
if isinstance(response, StreamingResponse):
|
||||
# Streaming response: wrap the generator to convert SSE format
|
||||
return StreamingResponse(
|
||||
openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model),
|
||||
openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model, input_tokens=input_tokens),
|
||||
media_type='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
@@ -1723,22 +1948,42 @@ async def generate_messages(
|
||||
},
|
||||
)
|
||||
elif isinstance(response, dict):
|
||||
return convert_openai_to_anthropic_response(response, model=requested_model)
|
||||
return convert_openai_to_anthropic_response(response, model=requested_model, input_tokens=input_tokens)
|
||||
else:
|
||||
# Passthrough for error responses (JSONResponse, PlainTextResponse, etc.)
|
||||
return response
|
||||
|
||||
|
||||
async def verify_chat_ownership(chat_id: str | None, user) -> None:
|
||||
"""Temporary chats are per-socket and unsaved, so they have no owner to check."""
|
||||
if not chat_id or is_temporary_chat_id(chat_id):
|
||||
return
|
||||
|
||||
# Channel messages need the membership and write-access gate that only /api/chat/completions has.
|
||||
if chat_id.startswith('channel:'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Channel chats are not supported on this endpoint',
|
||||
)
|
||||
|
||||
if user.role != 'admin' and not await Chats.is_chat_owner(chat_id, user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
@app.post('/api/chat/completed')
|
||||
async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
"""Deprecated: outlet filters now run inline during chat completion.
|
||||
Kept for backward compatibility with external integrations."""
|
||||
await verify_chat_ownership(form_data.get('chat_id'), user)
|
||||
|
||||
try:
|
||||
model_item = form_data.pop('model_item', {})
|
||||
|
||||
if model_item.get('direct', False):
|
||||
request.state.direct = True
|
||||
request.state.model = model_item
|
||||
await _set_direct_model(request, model_item, user)
|
||||
|
||||
return await chat_completed_handler(request, form_data, user)
|
||||
except Exception as e:
|
||||
@@ -1750,12 +1995,13 @@ async def chat_completed(request: Request, form_data: dict, user=Depends(get_ver
|
||||
|
||||
@app.post('/api/chat/actions/{action_id}')
|
||||
async def chat_action(request: Request, action_id: str, form_data: dict, user=Depends(get_verified_user)):
|
||||
await verify_chat_ownership(form_data.get('chat_id'), user)
|
||||
|
||||
try:
|
||||
model_item = form_data.pop('model_item', {})
|
||||
|
||||
if model_item.get('direct', False):
|
||||
request.state.direct = True
|
||||
request.state.model = model_item
|
||||
await _set_direct_model(request, model_item, user)
|
||||
|
||||
return await chat_action_handler(request, action_id, form_data, user)
|
||||
except Exception as e:
|
||||
@@ -1781,8 +2027,8 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
|
||||
|
||||
@app.get('/api/tasks/chat/{chat_id:path}')
|
||||
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
|
||||
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
|
||||
socket_id = chat_id[len('local:') :]
|
||||
socket_id = get_temporary_chat_session_id(chat_id)
|
||||
if socket_id:
|
||||
owner_id = get_user_id_from_session_pool(socket_id)
|
||||
if owner_id != user.id and user.role != 'admin':
|
||||
return {'task_ids': []}
|
||||
@@ -1799,8 +2045,8 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
|
||||
|
||||
@app.post('/api/tasks/chat/{chat_id:path}/stop')
|
||||
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
|
||||
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
|
||||
socket_id = chat_id[len('local:') :]
|
||||
socket_id = get_temporary_chat_session_id(chat_id)
|
||||
if socket_id:
|
||||
owner_id = get_user_id_from_session_pool(socket_id)
|
||||
if owner_id != user.id and user.role != 'admin':
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
@@ -1852,6 +2098,7 @@ async def get_app_config(request: Request):
|
||||
license_metadata = getattr(app.state, 'LICENSE_METADATA', None)
|
||||
user_count = await Users.get_num_users() if license_metadata else None
|
||||
config = await Config.get_many(
|
||||
'oauth.enable',
|
||||
'oauth.auto_redirect',
|
||||
'ldap.enable',
|
||||
'ui.enable_signup',
|
||||
@@ -1865,6 +2112,7 @@ async def get_app_config(request: Request):
|
||||
'calendar.enable',
|
||||
'automations.enable',
|
||||
'notes.enable',
|
||||
'chat.context_compaction.enable',
|
||||
'web.search.enable',
|
||||
'web.search.confirmation.enable',
|
||||
'web.search.confirmation.content',
|
||||
@@ -1905,7 +2153,13 @@ async def get_app_config(request: Request):
|
||||
'version': VERSION,
|
||||
'default_locale': str(DEFAULT_LOCALE),
|
||||
'oauth': {
|
||||
'providers': {name: config.get('name', name) for name, config in OAUTH_PROVIDERS.items()},
|
||||
# Hide providers (and thus the login buttons / auto-redirect) when OAuth
|
||||
# is disabled, without clearing the admin's provider configuration.
|
||||
'providers': (
|
||||
{name: provider.get('name', name) for name, provider in OAUTH_PROVIDERS.items()}
|
||||
if config.get('oauth.enable', True)
|
||||
else {}
|
||||
),
|
||||
'auto_redirect': config.get('oauth.auto_redirect'),
|
||||
},
|
||||
'features': {
|
||||
@@ -1927,12 +2181,14 @@ async def get_app_config(request: Request):
|
||||
'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
|
||||
'enable_easter_eggs': ENABLE_EASTER_EGGS,
|
||||
'enable_direct_connections': config.get('direct.enable'),
|
||||
'enable_plugins': ENABLE_PLUGINS,
|
||||
'enable_folders': config.get('folders.enable'),
|
||||
'folder_max_file_count': config.get('folders.max_file_count'),
|
||||
'enable_channels': config.get('channels.enable'),
|
||||
'enable_calendar': config.get('calendar.enable'),
|
||||
'enable_automations': config.get('automations.enable'),
|
||||
'enable_notes': config.get('notes.enable'),
|
||||
'enable_context_compaction': config.get('chat.context_compaction.enable'),
|
||||
'enable_web_search': config.get('web.search.enable'),
|
||||
'enable_web_search_confirmation': config.get('web.search.confirmation.enable'),
|
||||
'web_search_confirmation_content': config.get('web.search.confirmation.content'),
|
||||
|
||||
@@ -9,6 +9,8 @@ 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.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
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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')
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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')
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
"""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')
|
||||
@@ -11,6 +11,11 @@ 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
|
||||
@@ -23,7 +28,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" or "group"
|
||||
principal_type = Column(Text, nullable=False) # "user", "group", or "anyone"
|
||||
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)
|
||||
@@ -163,12 +168,14 @@ 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 ('user', 'group'):
|
||||
if principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE):
|
||||
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] = {
|
||||
@@ -186,7 +193,11 @@ 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'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read':
|
||||
if (
|
||||
grant['principal_type'] == PRINCIPAL_TYPE_USER
|
||||
and grant['principal_id'] == WILDCARD_PRINCIPAL_ID
|
||||
and grant['permission'] == 'read'
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -196,7 +207,25 @@ 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'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write':
|
||||
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'
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -206,7 +235,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'] == 'user' and grant['principal_id'] != '*':
|
||||
if grant['principal_type'] == PRINCIPAL_TYPE_USER and grant['principal_id'] != WILDCARD_PRINCIPAL_ID:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -223,12 +252,27 @@ 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))
|
||||
== 'user'
|
||||
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) != '*'
|
||||
== PRINCIPAL_TYPE_USER
|
||||
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None))
|
||||
!= WILDCARD_PRINCIPAL_ID
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
@@ -316,7 +360,6 @@ class AccessGrantsTable:
|
||||
)
|
||||
db.add(grant)
|
||||
await db.commit()
|
||||
await db.refresh(grant)
|
||||
return AccessGrantModel.model_validate(grant)
|
||||
|
||||
async def revoke_access(
|
||||
@@ -494,6 +537,28 @@ 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,
|
||||
|
||||
@@ -12,6 +12,7 @@ from open_webui.models.users import User, UserModel, UserProfileImageResponse, U
|
||||
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__)
|
||||
@@ -124,18 +125,20 @@ class AuthsTable:
|
||||
)
|
||||
session.add(credential)
|
||||
|
||||
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)
|
||||
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
|
||||
return created_user if credential and created_user else None
|
||||
|
||||
async def authenticate_user(
|
||||
|
||||
@@ -21,6 +21,7 @@ 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)
|
||||
@@ -31,7 +32,10 @@ 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'),)
|
||||
__table_args__ = (
|
||||
Index('ix_automation_next_run', 'next_run_at'),
|
||||
Index('ix_automation_user_folder', 'user_id', 'folder_id'),
|
||||
)
|
||||
|
||||
|
||||
class AutomationRun(Base):
|
||||
@@ -72,6 +76,7 @@ class AutomationModel(BaseModel):
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
folder_id: Optional[str] = None
|
||||
name: str
|
||||
data: dict
|
||||
meta: Optional[dict] = None
|
||||
@@ -96,6 +101,7 @@ class AutomationRunModel(BaseModel):
|
||||
|
||||
class AutomationForm(BaseModel):
|
||||
name: str
|
||||
folder_id: Optional[str] = None
|
||||
data: AutomationData
|
||||
meta: Optional[dict] = None
|
||||
is_active: Optional[bool] = True
|
||||
@@ -129,6 +135,7 @@ 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,
|
||||
@@ -139,7 +146,6 @@ 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:
|
||||
@@ -165,6 +171,7 @@ 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,
|
||||
@@ -172,6 +179,9 @@ class AutomationTable:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Automation).filter_by(user_id=user_id)
|
||||
|
||||
if folder_id is not None:
|
||||
stmt = stmt.filter(Automation.folder_id == (folder_id or None))
|
||||
|
||||
if query:
|
||||
search = f'%{query}%'
|
||||
# Search in name and prompt inside JSON data
|
||||
@@ -217,6 +227,7 @@ 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:
|
||||
@@ -224,9 +235,25 @@ 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,
|
||||
@@ -241,7 +268,6 @@ 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:
|
||||
@@ -324,7 +350,6 @@ 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]:
|
||||
|
||||
@@ -241,11 +241,11 @@ class CalendarTable:
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> CalendarModel:
|
||||
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)
|
||||
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)
|
||||
)
|
||||
return CalendarModel.model_validate(cal_data)
|
||||
return calendar_model
|
||||
|
||||
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,9 +500,12 @@ 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 user is an attendee
|
||||
# Also get event IDs where the user is an attendee, excluding invites they declined
|
||||
attendee_event_ids_result = await db.execute(
|
||||
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
|
||||
select(CalendarEventAttendee.event_id).filter(
|
||||
CalendarEventAttendee.user_id == user_id,
|
||||
CalendarEventAttendee.status != 'declined',
|
||||
)
|
||||
)
|
||||
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
|
||||
|
||||
@@ -764,22 +767,32 @@ 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.
|
||||
"""Replace all attendees for an event ({user_id, meta?} per dict).
|
||||
|
||||
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
|
||||
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.
|
||||
"""
|
||||
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=att['user_id'],
|
||||
status=att.get('status', 'pending'),
|
||||
user_id=user_id,
|
||||
status=existing_status.get(user_id, 'pending'),
|
||||
meta=att.get('meta'),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
|
||||
@@ -266,11 +266,11 @@ class ChannelTable:
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> ChannelModel:
|
||||
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)
|
||||
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)
|
||||
)
|
||||
return ChannelModel.model_validate(channel_data)
|
||||
return channel_model
|
||||
|
||||
async def _collect_unique_user_ids(
|
||||
self,
|
||||
@@ -869,7 +869,6 @@ 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:
|
||||
|
||||
@@ -1,7 +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
|
||||
@@ -47,6 +50,17 @@ 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')
|
||||
@@ -72,6 +86,40 @@ 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
|
||||
####################
|
||||
@@ -100,6 +148,7 @@ 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)
|
||||
@@ -142,6 +191,7 @@ 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
|
||||
@@ -192,6 +242,8 @@ class ChatMessageTable:
|
||||
existing.sources = data.get('sources')
|
||||
if 'embeds' in data:
|
||||
existing.embeds = data.get('embeds')
|
||||
if 'meta' in data:
|
||||
existing.meta = data.get('meta')
|
||||
if 'done' in data:
|
||||
existing.done = data.get('done', True)
|
||||
if 'status_history' in data or 'statusHistory' in data:
|
||||
@@ -207,7 +259,6 @@ class ChatMessageTable:
|
||||
existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
|
||||
existing.updated_at = now
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return ChatMessageModel.model_validate(existing)
|
||||
else:
|
||||
# Insert new
|
||||
@@ -225,6 +276,7 @@ class ChatMessageTable:
|
||||
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'),
|
||||
@@ -235,7 +287,6 @@ class ChatMessageTable:
|
||||
)
|
||||
db.add(message)
|
||||
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]:
|
||||
@@ -243,6 +294,21 @@ class ChatMessageTable:
|
||||
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(
|
||||
@@ -584,6 +650,233 @@ 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,
|
||||
|
||||
+584
-177
File diff suppressed because it is too large
Load Diff
@@ -46,8 +46,10 @@ API_CONFIG_FIELDS = (
|
||||
'auth_type',
|
||||
'headers',
|
||||
'azure',
|
||||
'api_type',
|
||||
'api_version',
|
||||
'extra_params',
|
||||
'passthrough_params',
|
||||
)
|
||||
|
||||
|
||||
@@ -247,6 +249,10 @@ class Config(Base):
|
||||
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))
|
||||
|
||||
@@ -165,7 +165,6 @@ 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:
|
||||
|
||||
@@ -142,7 +142,6 @@ 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:
|
||||
|
||||
@@ -58,6 +58,7 @@ 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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
|
||||
# local imports
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.users import UserResponse, Users
|
||||
from open_webui.models.users import User, UserResponse, Users, UserSettings
|
||||
from open_webui.utils.valves import decrypt_valves, encrypt_valves
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update
|
||||
@@ -42,7 +42,7 @@ class FunctionMeta(BaseModel):
|
||||
|
||||
class FunctionModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
@@ -58,7 +58,7 @@ class FunctionModel(BaseModel):
|
||||
# --- form / schema definitions ---
|
||||
class FunctionWithValvesModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
@@ -79,7 +79,7 @@ class FunctionWithValvesModel(BaseModel):
|
||||
|
||||
class FunctionResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
type: str
|
||||
name: str
|
||||
meta: FunctionMeta
|
||||
@@ -129,7 +129,6 @@ 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:
|
||||
@@ -275,6 +274,18 @@ 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))
|
||||
@@ -283,8 +294,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:
|
||||
function = await db.get(Function, id)
|
||||
return decrypt_valves(function.valves if function else None)
|
||||
result = await db.execute(select(Function.valves).filter_by(id=id))
|
||||
return decrypt_valves(result.scalar_one_or_none())
|
||||
except Exception as e:
|
||||
log.exception(f'Error getting function valves by id {id}: {e}')
|
||||
return None
|
||||
@@ -300,8 +311,7 @@ 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)))
|
||||
functions = result.all()
|
||||
return {f.id: decrypt_valves(f.valves) for f in functions}
|
||||
return {id: decrypt_valves(valves) for id, valves in result.all()}
|
||||
except Exception as e:
|
||||
log.exception(f'Error batch-fetching function valves: {e}')
|
||||
return {}
|
||||
@@ -315,7 +325,6 @@ class FunctionsTable:
|
||||
function.valves = encrypt_valves(valves)
|
||||
function.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(function)
|
||||
return FunctionModel.model_validate(function)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -335,7 +344,6 @@ class FunctionsTable:
|
||||
|
||||
function.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(function)
|
||||
return FunctionModel.model_validate(function)
|
||||
else:
|
||||
return None
|
||||
@@ -347,8 +355,11 @@ class FunctionsTable:
|
||||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> dict | None:
|
||||
try:
|
||||
user = await Users.get_user_by_id(user_id, db=db)
|
||||
user_settings = user.settings.model_dump() if user.settings else {}
|
||||
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 {}
|
||||
|
||||
# Check if user has "functions" and "valves" settings
|
||||
if 'functions' not in user_settings:
|
||||
|
||||
@@ -36,6 +36,9 @@ 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,
|
||||
@@ -149,6 +152,7 @@ class KnowledgeDirectoryForm(BaseModel):
|
||||
####################
|
||||
class KnowledgeUserModel(KnowledgeModel):
|
||||
user: Optional[UserResponse] = None
|
||||
file_count: int | None = None
|
||||
|
||||
|
||||
class KnowledgeResponse(KnowledgeModel):
|
||||
@@ -191,11 +195,11 @@ class KnowledgeTable:
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> KnowledgeModel:
|
||||
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)
|
||||
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)
|
||||
)
|
||||
return KnowledgeModel.model_validate(knowledge_data)
|
||||
return knowledge_model
|
||||
|
||||
async def insert_new_knowledge(
|
||||
self, user_id: str, form_data: KnowledgeForm, db: Optional[AsyncSession] = None
|
||||
@@ -308,7 +312,17 @@ class KnowledgeTable:
|
||||
permission='read',
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
|
||||
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())
|
||||
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
total = count_result.scalar()
|
||||
@@ -322,6 +336,14 @@ 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:
|
||||
@@ -336,6 +358,7 @@ class KnowledgeTable:
|
||||
)
|
||||
).model_dump(),
|
||||
'user': (UserModel.model_validate(user).model_dump() if user else None),
|
||||
'file_count': file_counts.get(knowledge_base.id, 0),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -469,20 +492,16 @@ class KnowledgeTable:
|
||||
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
|
||||
# One grants query for all non-owned knowledge bases instead of one each
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids]
|
||||
|
||||
async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
|
||||
try:
|
||||
@@ -665,9 +684,25 @@ 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:
|
||||
files = await self.get_files_by_id(knowledge_id, db=db)
|
||||
return [FileMetadataResponse(**file.model_dump()) for file in files]
|
||||
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()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Literal
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, select
|
||||
from sqlalchemy import JSON, BigInteger, Column, Index, String, Text, delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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)
|
||||
@@ -70,7 +71,6 @@ class MemoriesTable:
|
||||
)
|
||||
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(
|
||||
@@ -101,7 +101,6 @@ class MemoriesTable:
|
||||
memory.updated_at = int(time.time())
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(memory)
|
||||
return MemoryModel.model_validate(memory)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from copy import deepcopy
|
||||
from typing import Any, Optional
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
@@ -22,6 +23,38 @@ log = logging.getLogger(__name__)
|
||||
_warned_profile_urls: set[str] = set()
|
||||
|
||||
|
||||
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 ---
|
||||
|
||||
|
||||
@@ -37,6 +70,7 @@ 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')
|
||||
|
||||
@@ -56,6 +90,11 @@ 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):
|
||||
@@ -152,11 +191,19 @@ class ModelsTable:
|
||||
access_grants: list[AccessGrantModel | None] = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ModelModel:
|
||||
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)
|
||||
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)
|
||||
)
|
||||
return ModelModel.model_validate(model_data)
|
||||
return model_model
|
||||
|
||||
async def insert_new_model(
|
||||
self, form_data: ModelForm, user_id: str, db: AsyncSession | None = None
|
||||
@@ -173,7 +220,6 @@ 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:
|
||||
@@ -256,26 +302,26 @@ class ModelsTable:
|
||||
]
|
||||
|
||||
async def get_models_by_user_id(
|
||||
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = 'write',
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> list[ModelUserResponse]:
|
||||
models = await self.get_models(db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
|
||||
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
|
||||
# One grants query for all non-owned models instead of one per model
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_ids=[model.id for model in models if model.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [model for model in models if model.user_id == user_id or model.id in accessible_ids]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
|
||||
return AccessGrants.has_permission_filter(
|
||||
@@ -483,7 +529,6 @@ 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:
|
||||
@@ -510,13 +555,12 @@ class ModelsTable:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Model).filter_by(id=id))
|
||||
model_obj = result.scalars().first()
|
||||
if not model_obj:
|
||||
model = result.scalars().first()
|
||||
if not model:
|
||||
return None
|
||||
model_obj.updated_at = int(time.time())
|
||||
model.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(model_obj)
|
||||
return await self._to_model_model(model_obj, db=db)
|
||||
return await self._to_model_model(model, db=db)
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to update the model updated_at by id {id}: {e}')
|
||||
return None
|
||||
|
||||
@@ -106,11 +106,12 @@ class NoteTable:
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> NoteModel:
|
||||
# We exclude access_grants to inject them
|
||||
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)
|
||||
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)
|
||||
)
|
||||
return NoteModel.model_validate(note_data)
|
||||
return note_model
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
|
||||
return AccessGrants.has_permission_filter(
|
||||
@@ -308,9 +309,12 @@ class NoteTable:
|
||||
if 'title' in form_data:
|
||||
note.title = form_data['title']
|
||||
if 'data' in form_data:
|
||||
note.data = {**note.data, **form_data['data']}
|
||||
note.data = {**(note.data or {}), **(form_data['data'] or {})}
|
||||
if 'meta' in form_data:
|
||||
note.meta = {**note.meta, **form_data['meta']}
|
||||
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)
|
||||
|
||||
if 'access_grants' in form_data:
|
||||
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)
|
||||
|
||||
@@ -128,7 +128,6 @@ class OAuthSessionTable:
|
||||
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
|
||||
if result:
|
||||
# Make a copy of the model data before closing session
|
||||
|
||||
@@ -70,7 +70,6 @@ class PromptHistoryTable:
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(history)
|
||||
return PromptHistoryModel.model_validate(history)
|
||||
|
||||
async def get_history_by_prompt_id(
|
||||
|
||||
@@ -103,11 +103,11 @@ class PromptsTable:
|
||||
access_grants: list[AccessGrantModel | None] = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> PromptModel:
|
||||
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)
|
||||
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)
|
||||
)
|
||||
return PromptModel.model_validate(prompt_data)
|
||||
return prompt_model
|
||||
|
||||
async def insert_new_prompt(
|
||||
self, user_id: str, form_data: PromptForm, db: AsyncSession | None = None
|
||||
@@ -132,7 +132,6 @@ class PromptsTable:
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record) # populate generated defaults
|
||||
|
||||
await AccessGrants.set_access_grants(
|
||||
'prompt',
|
||||
@@ -169,7 +168,6 @@ 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:
|
||||
@@ -637,7 +635,6 @@ 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:
|
||||
|
||||
@@ -113,11 +113,11 @@ class SkillsTable:
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> SkillModel:
|
||||
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)
|
||||
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)
|
||||
)
|
||||
return SkillModel.model_validate(skill_data)
|
||||
return skill_model
|
||||
|
||||
async def insert_new_skill(
|
||||
self,
|
||||
@@ -137,7 +137,6 @@ 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)
|
||||
@@ -259,7 +258,26 @@ class SkillsTable:
|
||||
permission='read',
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(Skill.updated_at.desc())
|
||||
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())
|
||||
|
||||
# Count BEFORE pagination
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
@@ -307,8 +325,8 @@ class SkillsTable:
|
||||
if access_grants is not None:
|
||||
await AccessGrants.set_access_grants('skill', id, access_grants, db=db)
|
||||
|
||||
skill = await db.get(Skill, id)
|
||||
await db.refresh(skill)
|
||||
# populate_existing: the Core update above bypasses any identity-map copy
|
||||
skill = await db.get(Skill, id, populate_existing=True)
|
||||
return await self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -324,7 +342,6 @@ 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:
|
||||
|
||||
@@ -63,7 +63,6 @@ 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)
|
||||
|
||||
@@ -41,9 +41,10 @@ class ToolMeta(BaseModel):
|
||||
|
||||
class ToolModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
content: str
|
||||
# None when listed with defer_content=True (source skipped for listings)
|
||||
content: str | None = None
|
||||
specs: list[dict]
|
||||
meta: ToolMeta
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
@@ -65,7 +66,7 @@ class ToolUserModel(ToolModel):
|
||||
|
||||
class ToolResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
meta: ToolMeta
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
@@ -105,11 +106,11 @@ class ToolsTable:
|
||||
access_grants: list[AccessGrantModel | None] = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ToolModel:
|
||||
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)
|
||||
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)
|
||||
)
|
||||
return ToolModel.model_validate(tool_data)
|
||||
return tool_model
|
||||
|
||||
async def insert_new_tool(
|
||||
self,
|
||||
@@ -131,7 +132,6 @@ 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)
|
||||
@@ -171,11 +171,18 @@ class ToolsTable:
|
||||
|
||||
async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Tool).order_by(Tool.updated_at.desc())
|
||||
if defer_content:
|
||||
stmt = stmt
|
||||
result = await db.execute(stmt)
|
||||
all_tools = result.scalars().all()
|
||||
# Skip Tool.content (plugin source, potentially large) via a
|
||||
# column select; Row attributes satisfy from_attributes.
|
||||
result = await db.execute(
|
||||
select(
|
||||
Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at
|
||||
).order_by(Tool.updated_at.desc())
|
||||
)
|
||||
all_tools = result.all()
|
||||
else:
|
||||
result = await db.execute(select(Tool).order_by(Tool.updated_at.desc()))
|
||||
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]
|
||||
@@ -214,20 +221,16 @@ class ToolsTable:
|
||||
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 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
|
||||
# One grants query for all non-owned tools instead of one per tool
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='tool',
|
||||
resource_ids=[tool.id for tool in tools if tool.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids]
|
||||
|
||||
async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
|
||||
try:
|
||||
@@ -301,8 +304,8 @@ class ToolsTable:
|
||||
if access_grants is not None:
|
||||
await AccessGrants.set_access_grants('tool', id, access_grants, db=db)
|
||||
|
||||
tool = await db.get(Tool, id)
|
||||
await db.refresh(tool)
|
||||
# populate_existing: the Core update above bypasses any identity-map copy
|
||||
tool = await db.get(Tool, id, populate_existing=True)
|
||||
return await self._to_tool_model(tool, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -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_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
@@ -69,6 +69,7 @@ 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,6 +106,7 @@ 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
|
||||
@@ -126,6 +128,11 @@ 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
|
||||
@@ -302,7 +309,6 @@ 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
|
||||
@@ -566,13 +572,6 @@ 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())
|
||||
@@ -589,7 +588,6 @@ 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(
|
||||
@@ -602,7 +600,6 @@ 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(
|
||||
@@ -622,7 +619,6 @@ class UsersTable:
|
||||
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)
|
||||
@@ -643,7 +639,6 @@ class UsersTable:
|
||||
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(
|
||||
@@ -662,7 +657,6 @@ 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:
|
||||
@@ -673,7 +667,6 @@ 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
|
||||
@@ -688,7 +681,6 @@ 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:
|
||||
@@ -735,8 +727,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).where(User.id.in_(user_ids)))
|
||||
return [u.id for u in result.scalars().all()]
|
||||
result = await session.execute(select(User.id).where(User.id.in_(user_ids)))
|
||||
return list(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:
|
||||
@@ -762,11 +754,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:
|
||||
user = await session.get(User, user_id)
|
||||
if user and user.last_active_at:
|
||||
last_active_at = await session.scalar(select(User.last_active_at).where(User.id == user_id))
|
||||
if last_active_at:
|
||||
# Consider user active if last_active_at within the last 3 minutes
|
||||
three_minutes_ago = int(time.time()) - 180
|
||||
return user.last_active_at >= three_minutes_ago
|
||||
return last_active_at >= three_minutes_ago
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -103,7 +104,7 @@ async def _retrieve_qdrant(connection, auth_config, knowledge, query, count, emb
|
||||
source_config = _source_config(knowledge)
|
||||
vector_field = source_config.get('vector_field') or None
|
||||
|
||||
vector = await embedding_function(query)
|
||||
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
|
||||
|
||||
def _search():
|
||||
client = QdrantClient(
|
||||
@@ -152,7 +153,7 @@ async def _retrieve_milvus(connection, auth_config, knowledge, query, count, emb
|
||||
content_field = source_config.get('content_field') or 'data.text'
|
||||
metadata_field = source_config.get('metadata_field') or 'metadata'
|
||||
|
||||
vector = await embedding_function(query)
|
||||
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
|
||||
|
||||
def _search():
|
||||
client_kwargs = {
|
||||
@@ -229,7 +230,7 @@ async def _retrieve_pgvector(connection, auth_config, knowledge, query, count, e
|
||||
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)
|
||||
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
|
||||
|
||||
def _search():
|
||||
from psycopg import sql
|
||||
|
||||
@@ -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 get_custom_headers, include_user_info_headers
|
||||
from open_webui.utils.headers import include_user_info_headers, parse_custom_headers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,6 +19,7 @@ class ExternalDocumentLoader(BaseLoader):
|
||||
api_key: str,
|
||||
mime_type=None,
|
||||
user=None,
|
||||
user_groups=None,
|
||||
headers=None,
|
||||
metadata=None,
|
||||
**kwargs,
|
||||
@@ -30,6 +31,7 @@ class ExternalDocumentLoader(BaseLoader):
|
||||
self.mime_type = mime_type
|
||||
|
||||
self.user = user
|
||||
self.user_groups = user_groups
|
||||
self.headers = headers
|
||||
self.metadata = metadata
|
||||
|
||||
@@ -49,7 +51,7 @@ class ExternalDocumentLoader(BaseLoader):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers.update(get_custom_headers(self.headers, self.user, self.metadata))
|
||||
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)
|
||||
|
||||
@@ -11,7 +11,6 @@ from langchain_community.document_loaders import (
|
||||
BSHTMLLoader,
|
||||
CSVLoader,
|
||||
Docx2txtLoader,
|
||||
OutlookMessageLoader,
|
||||
PyPDFLoader,
|
||||
TextLoader,
|
||||
YoutubeLoader,
|
||||
@@ -27,7 +26,8 @@ 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 PaddleOCRVLLoader
|
||||
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
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -206,6 +206,9 @@ 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,
|
||||
@@ -246,6 +249,7 @@ 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
|
||||
|
||||
@@ -264,6 +268,13 @@ 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:
|
||||
@@ -303,13 +314,20 @@ class Loader:
|
||||
try:
|
||||
raw.decode('utf-8')
|
||||
return 'utf-8'
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
except UnicodeDecodeError as e:
|
||||
first_non_utf8 = e.start
|
||||
|
||||
# Use chardet as a hint, not as ground truth
|
||||
import chardet
|
||||
|
||||
detected = chardet.detect(raw)
|
||||
# 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_enc = (detected.get('encoding') or '').lower().replace('-', '').replace('_', '')
|
||||
|
||||
# Map chardet's detected encoding to the correct superset codec.
|
||||
@@ -422,6 +440,7 @@ 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,
|
||||
@@ -554,8 +573,14 @@ class Loader:
|
||||
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_TOKEN') != '':
|
||||
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
|
||||
):
|
||||
loader = PaddleOCRVLLoader(
|
||||
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
|
||||
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
|
||||
@@ -654,7 +679,18 @@ class Loader:
|
||||
)
|
||||
loader = PptxLoader(file_path)
|
||||
elif file_ext == 'msg':
|
||||
loader = OutlookMessageLoader(file_path)
|
||||
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'
|
||||
)
|
||||
elif file_ext == 'odt':
|
||||
try:
|
||||
from langchain_community.document_loaders import UnstructuredODTLoader
|
||||
|
||||
@@ -5,12 +5,13 @@ import os
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -39,6 +40,7 @@ class MistralLoader:
|
||||
max_retries: int = 3,
|
||||
enable_debug_logging: bool = False,
|
||||
use_base64: bool = False,
|
||||
user: Optional[Any] = None,
|
||||
):
|
||||
"""
|
||||
Initializes the loader with enhanced features.
|
||||
@@ -50,6 +52,8 @@ class MistralLoader:
|
||||
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.')
|
||||
@@ -63,6 +67,7 @@ class MistralLoader:
|
||||
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
|
||||
@@ -82,6 +87,8 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -11,6 +11,9 @@ 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."""
|
||||
@@ -46,8 +49,7 @@ class PaddleOCRVLLoader:
|
||||
|
||||
# Detect fileType based on file extension
|
||||
ext = self.file_path.lower().split('.')[-1]
|
||||
image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
|
||||
file_type = 1 if ext in image_extensions else 0
|
||||
file_type = 1 if ext in PADDLEOCR_VL_IMAGE_EXTENSIONS else 0
|
||||
|
||||
payload = {
|
||||
'file': file_data,
|
||||
|
||||
@@ -37,6 +37,7 @@ 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
|
||||
@@ -47,9 +48,10 @@ 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.web.utils import get_web_loader
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.access_control.files import get_owner_accessible_folder_files, has_access_to_file
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.misc import get_content_from_message, get_message_list
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,6 +73,20 @@ LOADER_CONFIG_KEYS = {
|
||||
'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',
|
||||
@@ -126,6 +142,7 @@ def get_loader(request, url: str, config: dict):
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -615,7 +632,7 @@ 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 = hashlib.sha256(document.encode()).hexdigest() # Compute a hash for uniqueness
|
||||
doc_hash = (metadata or {}).get(CHUNK_HASH_KEY) or _content_hash(document)
|
||||
|
||||
if doc_hash not in combined.keys():
|
||||
combined[doc_hash] = (distance, document, metadata)
|
||||
@@ -1250,7 +1267,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, always allowed
|
||||
- web-search-* → ephemeral per-query collections, owner-bound to web-search-{user.id}-*
|
||||
- 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
|
||||
@@ -1285,10 +1302,10 @@ async def filter_accessible_collections(
|
||||
if name == f'user-memory-{user.id}':
|
||||
validated.add(name)
|
||||
elif name.startswith('web-search-'):
|
||||
# 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)
|
||||
# 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)
|
||||
else:
|
||||
# May be a knowledge-base ID or a legacy/ephemeral collection.
|
||||
# If it IS a KB, enforce access control. If no such KB
|
||||
@@ -1318,11 +1335,28 @@ async def get_sources_from_items(
|
||||
full_context=False,
|
||||
user: UserModel | None = None,
|
||||
):
|
||||
log.debug(f'items: {items} {queries} {embedding_function} {reranking_function} {full_context}')
|
||||
log.debug('items: %s %s %s %s %s', 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
|
||||
@@ -1390,7 +1424,10 @@ 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{m.get("content")}\n' for m in message_list]
|
||||
[
|
||||
f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n'
|
||||
for m in message_list
|
||||
]
|
||||
)
|
||||
|
||||
# User has access to the chat
|
||||
@@ -1429,6 +1466,7 @@ 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', '')]],
|
||||
@@ -1459,6 +1497,7 @@ 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}')
|
||||
@@ -1478,6 +1517,7 @@ 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(
|
||||
@@ -1500,6 +1540,7 @@ async def get_sources_from_items(
|
||||
resource_id=knowledge_base.id,
|
||||
permission='read',
|
||||
)
|
||||
or ('collection', item.get('id')) in folder_items
|
||||
):
|
||||
files = await Knowledges.get_files_by_id(knowledge_base.id)
|
||||
|
||||
@@ -1570,7 +1611,7 @@ async def get_sources_from_items(
|
||||
continue
|
||||
|
||||
# Filter out collections the user cannot read
|
||||
if user:
|
||||
if user and (item.get('type'), item.get('id')) not in folder_items:
|
||||
collection_names = await filter_accessible_collections(collection_names, user)
|
||||
if not collection_names:
|
||||
log.debug(f'access denied for all collections in item {item}')
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -56,13 +57,11 @@ class ChromaClient(VectorDBBase):
|
||||
)
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
# Check if the collection exists based on the collection name.
|
||||
# chromadb's list_collections() returns Collection objects (1.x), so a
|
||||
# bare `name in collections` membership test is always False — compare
|
||||
# against the names. (hasattr guard tolerates versions that yield names.)
|
||||
collections = self.client.list_collections()
|
||||
collection_names = [c.name if hasattr(c, 'name') else c for c in collections]
|
||||
return collection_name in collection_names
|
||||
try:
|
||||
self.client.get_collection(name=collection_name)
|
||||
return True
|
||||
except NotFoundError:
|
||||
return False
|
||||
|
||||
def delete_collection(self, collection_name: str):
|
||||
# Delete the collection based on the collection name.
|
||||
|
||||
@@ -25,7 +25,7 @@ from open_webui.retrieval.vector.main import (
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from pymilvus import Collection, DataType, FieldSchema, connections
|
||||
from pymilvus import DataType
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
||||
@@ -202,8 +202,6 @@ class MilvusClient(VectorDBBase):
|
||||
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}')
|
||||
@@ -218,16 +216,16 @@ class MilvusClient(VectorDBBase):
|
||||
|
||||
filter_string = ' && '.join(filter_expressions)
|
||||
|
||||
collection = Collection(f'{self.collection_prefix}_{collection_name}')
|
||||
collection.load()
|
||||
self.client.load_collection(collection_name=f'{self.collection_prefix}_{collection_name}')
|
||||
|
||||
try:
|
||||
log.info(
|
||||
f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}"
|
||||
)
|
||||
|
||||
iterator = collection.query_iterator(
|
||||
expr=filter_string,
|
||||
iterator = self.client.query_iterator(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
filter=filter_string,
|
||||
output_fields=[
|
||||
'id',
|
||||
'data',
|
||||
|
||||
@@ -23,14 +23,8 @@ from open_webui.retrieval.vector.main import (
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from pymilvus import (
|
||||
Collection,
|
||||
CollectionSchema,
|
||||
DataType,
|
||||
FieldSchema,
|
||||
connections,
|
||||
utility,
|
||||
)
|
||||
from pymilvus import DataType
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -70,12 +64,7 @@ class MilvusClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
# Milvus collection names can only contain numbers, letters, and underscores.
|
||||
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace('-', '_')
|
||||
connections.connect(
|
||||
alias='default',
|
||||
uri=MILVUS_URI,
|
||||
token=MILVUS_TOKEN,
|
||||
db_name=MILVUS_DB,
|
||||
)
|
||||
self.client = Client(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
|
||||
|
||||
# Main collection types for multi-tenancy
|
||||
self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories'
|
||||
@@ -116,53 +105,66 @@ class MilvusClient(VectorDBBase):
|
||||
return self.KNOWLEDGE_COLLECTION, resource_id
|
||||
|
||||
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
|
||||
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)
|
||||
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)
|
||||
|
||||
index_params = {
|
||||
'metric_type': MILVUS_METRIC_TYPE,
|
||||
'index_type': MILVUS_INDEX_TYPE,
|
||||
'params': {},
|
||||
}
|
||||
index_build_params = {}
|
||||
if MILVUS_INDEX_TYPE == 'HNSW':
|
||||
index_params['params'] = {
|
||||
index_build_params = {
|
||||
'M': MILVUS_HNSW_M,
|
||||
'efConstruction': MILVUS_HNSW_EFCONSTRUCTION,
|
||||
}
|
||||
elif MILVUS_INDEX_TYPE == 'IVF_FLAT':
|
||||
index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST}
|
||||
index_build_params = {'nlist': MILVUS_IVF_FLAT_NLIST}
|
||||
|
||||
collection.create_index('vector', index_params)
|
||||
collection.create_index(RESOURCE_ID_FIELD)
|
||||
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(f'Created shared collection: {mt_collection_name}')
|
||||
return collection
|
||||
|
||||
def _ensure_collection(self, mt_collection_name: str, dimension: int):
|
||||
if not utility.has_collection(mt_collection_name):
|
||||
if not self.client.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 utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return False
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
|
||||
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,
|
||||
)
|
||||
return len(res) > 0
|
||||
|
||||
def upsert(self, collection_name: str, items: List[VectorItem]):
|
||||
@@ -172,7 +174,6 @@ 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:
|
||||
@@ -195,7 +196,7 @@ class MilvusClient(VectorDBBase):
|
||||
)
|
||||
|
||||
try:
|
||||
collection.insert(entities)
|
||||
self.client.insert(collection_name=mt_collection, data=entities)
|
||||
except MilvusException as e:
|
||||
log.error(
|
||||
f'Milvus insert failed (collection={mt_collection}, '
|
||||
@@ -215,19 +216,18 @@ class MilvusClient(VectorDBBase):
|
||||
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
_validate_resource_id(resource_id)
|
||||
if not utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return None
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
self.client.load_collection(mt_collection)
|
||||
|
||||
search_params = {'metric_type': MILVUS_METRIC_TYPE, 'params': {}}
|
||||
results = collection.search(
|
||||
results = self.client.search(
|
||||
collection_name=mt_collection,
|
||||
data=vectors,
|
||||
anns_field='vector',
|
||||
param=search_params,
|
||||
search_params={'metric_type': MILVUS_METRIC_TYPE, 'params': {}},
|
||||
limit=limit,
|
||||
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||
filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
|
||||
output_fields=['id', 'text', 'metadata'],
|
||||
)
|
||||
|
||||
@@ -235,10 +235,11 @@ class MilvusClient(VectorDBBase):
|
||||
for hits in results:
|
||||
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
|
||||
for hit in hits:
|
||||
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)
|
||||
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'))
|
||||
ids.append(batch_ids)
|
||||
documents.append(batch_docs)
|
||||
metadatas.append(batch_metadatas)
|
||||
@@ -254,11 +255,9 @@ class MilvusClient(VectorDBBase):
|
||||
):
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
_validate_resource_id(resource_id)
|
||||
if not utility.has_collection(mt_collection):
|
||||
if not self.client.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
|
||||
@@ -270,30 +269,28 @@ class MilvusClient(VectorDBBase):
|
||||
_validate_metadata_key(key)
|
||||
expr.append(f"metadata['{key}'] == '{_escape_milvus_string(str(value))}'")
|
||||
|
||||
collection.delete(' and '.join(expr))
|
||||
self.client.delete(collection_name=mt_collection, filter=' and '.join(expr))
|
||||
|
||||
def reset(self):
|
||||
for collection_name in self.shared_collections:
|
||||
if utility.has_collection(collection_name):
|
||||
utility.drop_collection(collection_name)
|
||||
if self.client.has_collection(collection_name):
|
||||
self.client.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 utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
|
||||
self.client.delete(collection_name=mt_collection, filter=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 utility.has_collection(mt_collection):
|
||||
if not self.client.has_collection(mt_collection):
|
||||
return None
|
||||
|
||||
collection = Collection(mt_collection)
|
||||
collection.load()
|
||||
self.client.load_collection(mt_collection)
|
||||
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
|
||||
if filter:
|
||||
@@ -308,8 +305,9 @@ class MilvusClient(VectorDBBase):
|
||||
else:
|
||||
raise TypeError(f'Unsupported Milvus filter value type for key {key!r}: {type(value).__name__}')
|
||||
|
||||
iterator = collection.query_iterator(
|
||||
expr=' and '.join(expr),
|
||||
iterator = self.client.query_iterator(
|
||||
collection_name=mt_collection,
|
||||
filter=' and '.join(expr),
|
||||
output_fields=['id', 'text', 'metadata'],
|
||||
limit=limit if limit else -1,
|
||||
)
|
||||
|
||||
@@ -28,10 +28,10 @@ def build_firecrawl_url(base_url: str | None, path: str) -> str:
|
||||
|
||||
|
||||
def build_firecrawl_headers(api_key: str | None) -> dict[str, str]:
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {api_key or ""}',
|
||||
}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
return headers
|
||||
|
||||
|
||||
def get_firecrawl_timeout_seconds(timeout: Any) -> float | None:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import validators
|
||||
from open_webui.retrieval.web.utils import resolve_hostname
|
||||
from open_webui.utils.misc import is_host_allowed
|
||||
from open_webui.utils.misc import get_allow_block_lists, is_host_allowed
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -12,6 +13,16 @@ def get_filtered_results(results, filter_list):
|
||||
if not filter_list:
|
||||
return results
|
||||
|
||||
allow_list, block_list = get_allow_block_lists(filter_list)
|
||||
resolve_ips = False
|
||||
for entry in allow_list + block_list:
|
||||
try:
|
||||
ipaddress.ip_address(entry)
|
||||
except ValueError:
|
||||
continue
|
||||
resolve_ips = True
|
||||
break
|
||||
|
||||
filtered_results = []
|
||||
|
||||
for result in results:
|
||||
@@ -25,12 +36,13 @@ def get_filtered_results(results, filter_list):
|
||||
|
||||
hostnames = [domain]
|
||||
|
||||
try:
|
||||
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
|
||||
hostnames.extend(ipv4_addresses)
|
||||
hostnames.extend(ipv6_addresses)
|
||||
except Exception:
|
||||
pass
|
||||
if resolve_ips:
|
||||
try:
|
||||
ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
|
||||
hostnames.extend(ipv4_addresses)
|
||||
hostnames.extend(ipv6_addresses)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if is_host_allowed(hostnames, filter_list):
|
||||
filtered_results.append(result)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def search_openserp(
|
||||
base_url: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Query an OpenSERP instance and return normalised results.
|
||||
|
||||
OpenSERP aggregates results from 6 engines (google, bing, yandex,
|
||||
baidu, duckduckgo, ecosia) at once via ``/mega/search``.
|
||||
|
||||
No API key is required -- only a reachable OpenSERP base URL.
|
||||
"""
|
||||
url = f'{base_url.rstrip("/")}/mega/search'
|
||||
params = {'text': query, 'limit': count}
|
||||
|
||||
log.debug('searching OpenSERP at %s', url)
|
||||
|
||||
session = await get_session()
|
||||
async with session.get(url, params=params) as response:
|
||||
response.raise_for_status()
|
||||
payload = await response.json()
|
||||
|
||||
results = payload.get('results', [])
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=item.get('url', ''),
|
||||
title=item.get('title'),
|
||||
snippet=item.get('snippet'),
|
||||
)
|
||||
for item in results[:count]
|
||||
]
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import ssl
|
||||
from functools import lru_cache
|
||||
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, SEARXNG_CLIENT_CERT_FILE, SEARXNG_CLIENT_KEY_FILE
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
@@ -17,6 +20,19 @@ _SEARXNG_HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_ssl_context() -> bool | ssl.SSLContext:
|
||||
if not SEARXNG_CLIENT_CERT_FILE:
|
||||
return AIOHTTP_CLIENT_SESSION_SSL
|
||||
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.load_cert_chain(
|
||||
certfile=SEARXNG_CLIENT_CERT_FILE,
|
||||
keyfile=SEARXNG_CLIENT_KEY_FILE or None,
|
||||
)
|
||||
return ssl_context
|
||||
|
||||
|
||||
async def search_searxng(
|
||||
query_url: str,
|
||||
query: str,
|
||||
@@ -48,7 +64,12 @@ async def search_searxng(
|
||||
log.debug('searching %s', query_url)
|
||||
|
||||
session = await get_session()
|
||||
async with session.get(query_url, headers=_SEARXNG_HEADERS, params=params) as response:
|
||||
async with session.get(
|
||||
query_url,
|
||||
headers=_SEARXNG_HEADERS,
|
||||
params=params,
|
||||
ssl=_get_ssl_context(),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
payload = await response.json()
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, time, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
@@ -74,6 +75,34 @@ def resolve_hostname(hostname):
|
||||
return ipv4_addresses, ipv6_addresses
|
||||
|
||||
|
||||
def _is_global_addr(ip: str) -> bool:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
if not addr.is_global:
|
||||
return False
|
||||
if not isinstance(addr, ipaddress.IPv6Address):
|
||||
return True
|
||||
|
||||
embedded = []
|
||||
if addr.ipv4_mapped:
|
||||
embedded.append(addr.ipv4_mapped)
|
||||
if addr.sixtofour:
|
||||
embedded.append(addr.sixtofour)
|
||||
if addr.teredo:
|
||||
embedded.extend(addr.teredo)
|
||||
|
||||
b = addr.packed
|
||||
if b[:12] == b'\x00' * 12:
|
||||
embedded.append(ipaddress.IPv4Address(b[12:]))
|
||||
elif b[:12] == b'\x00\x64\xff\x9b' + b'\x00' * 8:
|
||||
embedded.append(ipaddress.IPv4Address(b[12:]))
|
||||
elif b[:6] == b'\x00\x64\xff\x9b\x00\x01':
|
||||
if b[8] != 0:
|
||||
return False
|
||||
embedded.append(ipaddress.IPv4Address(bytes((b[6], b[7], b[9], b[10]))))
|
||||
|
||||
return all(ip.is_global for ip in embedded)
|
||||
|
||||
|
||||
def validate_url(url: Union[str, Sequence[str]]):
|
||||
if isinstance(url, str):
|
||||
if isinstance(validators.url(url), validators.ValidationError):
|
||||
@@ -110,8 +139,7 @@ def validate_url(url: Union[str, Sequence[str]]):
|
||||
# Check if any of the resolved addresses are private
|
||||
# DNS rebinding is mitigated at the connection layer; see _SSRFSafeResolver / _SSRFSafeAdapter
|
||||
for ip in ipv4_addresses + ipv6_addresses:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
if not addr.is_global:
|
||||
if not _is_global_addr(ip):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
return True
|
||||
elif isinstance(url, Sequence):
|
||||
@@ -146,7 +174,7 @@ def _ssrf_safe_new_conn(self):
|
||||
raise OSError(f'getaddrinfo for {host!r} returned empty list')
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
for _, _, _, _, sa in infos:
|
||||
if not ipaddress.ip_address(sa[0]).is_global:
|
||||
if not _is_global_addr(sa[0]):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
err = None
|
||||
for fam, typ, proto, _, sa in infos:
|
||||
@@ -158,6 +186,11 @@ def _ssrf_safe_new_conn(self):
|
||||
if getattr(self, 'source_address', None):
|
||||
sock.bind(self.source_address)
|
||||
for opt in getattr(self, 'socket_options', None) or ():
|
||||
if len(opt) == 4 and isinstance(opt[3], str):
|
||||
# urllib3-future per-protocol form: (level, optname, value, "tcp"/"udp")
|
||||
if opt[3].lower() == 'tcp':
|
||||
sock.setsockopt(*opt[:3])
|
||||
continue
|
||||
sock.setsockopt(*opt)
|
||||
sock.connect(sa)
|
||||
return sock
|
||||
@@ -202,7 +235,7 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver):
|
||||
results = await super().resolve(host, port, family)
|
||||
if not ENABLE_LOCAL_WEB_FETCH:
|
||||
for entry in results:
|
||||
if not ipaddress.ip_address(entry['host']).is_global:
|
||||
if not _is_global_addr(entry['host']):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
return results
|
||||
|
||||
@@ -600,57 +633,63 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
||||
def _intercept_navigation_sync(self, route, request=None):
|
||||
req = request or route.request
|
||||
|
||||
if req.resource_type != 'document':
|
||||
route.continue_()
|
||||
return
|
||||
|
||||
try:
|
||||
validate_url(req.url)
|
||||
resp = route.fetch(max_redirects=0)
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
for _ in range(20):
|
||||
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
route.abort()
|
||||
return
|
||||
|
||||
location = resp.headers.get('location')
|
||||
if not location:
|
||||
break
|
||||
|
||||
url = urllib.parse.urljoin(resp.url, location)
|
||||
validate_url(url)
|
||||
resp = route.fetch(url=url, max_redirects=0)
|
||||
if not 300 <= resp.status < 400:
|
||||
break
|
||||
else:
|
||||
route.abort()
|
||||
return
|
||||
except Exception:
|
||||
route.abort()
|
||||
return
|
||||
|
||||
if AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
resp = route.fetch()
|
||||
else:
|
||||
try:
|
||||
resp = route.fetch(max_redirects=0)
|
||||
except TypeError:
|
||||
route.abort()
|
||||
return
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
route.abort()
|
||||
return
|
||||
|
||||
route.fulfill(response=resp)
|
||||
|
||||
async def _intercept_navigation(self, route, request=None):
|
||||
req = request or route.request
|
||||
|
||||
if req.resource_type != 'document':
|
||||
await route.continue_()
|
||||
return
|
||||
|
||||
try:
|
||||
await run_in_threadpool(validate_url, req.url)
|
||||
resp = await route.fetch(max_redirects=0)
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
for _ in range(20):
|
||||
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
location = resp.headers.get('location')
|
||||
if not location:
|
||||
break
|
||||
|
||||
url = urllib.parse.urljoin(resp.url, location)
|
||||
await run_in_threadpool(validate_url, url)
|
||||
resp = await route.fetch(url=url, max_redirects=0)
|
||||
if not 300 <= resp.status < 400:
|
||||
break
|
||||
else:
|
||||
await route.abort()
|
||||
return
|
||||
except Exception:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
if AIOHTTP_CLIENT_ALLOW_REDIRECTS:
|
||||
resp = await route.fetch()
|
||||
else:
|
||||
try:
|
||||
resp = await route.fetch(max_redirects=0)
|
||||
except TypeError:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
if 300 <= resp.status < 400:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
await route.fulfill(response=resp)
|
||||
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
@@ -664,24 +703,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
||||
else:
|
||||
browser = p.chromium.launch(headless=self.headless, proxy=self.proxy)
|
||||
|
||||
for url in self.urls:
|
||||
try:
|
||||
self._safe_process_url_sync(url)
|
||||
page = browser.new_page()
|
||||
page.route('**/*', self._intercept_navigation_sync)
|
||||
response = page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
raise ValueError(f'page.goto() returned None for url {url}')
|
||||
with browser:
|
||||
for url in self.urls:
|
||||
try:
|
||||
self._safe_process_url_sync(url)
|
||||
with browser.new_page(service_workers='block') as page:
|
||||
page.route('**/*', self._intercept_navigation_sync)
|
||||
page.route_web_socket('**/*', lambda ws_route: ws_route.close())
|
||||
response = page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
raise ValueError(f'page.goto() returned None for url {url}')
|
||||
|
||||
text = self.evaluator.evaluate(page, browser, response)
|
||||
metadata = {'source': url}
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error loading {url}: {e}')
|
||||
continue
|
||||
raise e
|
||||
browser.close()
|
||||
text = self.evaluator.evaluate(page, browser, response)
|
||||
metadata = {'source': url}
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error loading {url}: {e}')
|
||||
continue
|
||||
raise e
|
||||
|
||||
async def alazy_load(self) -> AsyncIterator[Document]:
|
||||
"""Safely load URLs asynchronously with support for remote browser."""
|
||||
@@ -694,24 +734,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
|
||||
else:
|
||||
browser = await p.chromium.launch(headless=self.headless, proxy=self.proxy)
|
||||
|
||||
for url in self.urls:
|
||||
try:
|
||||
await self._safe_process_url(url)
|
||||
page = await browser.new_page()
|
||||
await page.route('**/*', self._intercept_navigation)
|
||||
response = await page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
raise ValueError(f'page.goto() returned None for url {url}')
|
||||
async with browser:
|
||||
for url in self.urls:
|
||||
try:
|
||||
await self._safe_process_url(url)
|
||||
async with await browser.new_page(service_workers='block') as page:
|
||||
await page.route('**/*', self._intercept_navigation)
|
||||
await page.route_web_socket('**/*', lambda ws_route: ws_route.close())
|
||||
response = await page.goto(url, timeout=self.playwright_timeout)
|
||||
if response is None:
|
||||
raise ValueError(f'page.goto() returned None for url {url}')
|
||||
|
||||
text = await self.evaluator.evaluate_async(page, browser, response)
|
||||
metadata = {'source': url}
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error loading {url}: {e}')
|
||||
continue
|
||||
raise e
|
||||
await browser.close()
|
||||
text = await self.evaluator.evaluate_async(page, browser, response)
|
||||
metadata = {'source': url}
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error loading {url}: {e}')
|
||||
continue
|
||||
raise e
|
||||
|
||||
|
||||
class SafeWebBaseLoader(WebBaseLoader):
|
||||
@@ -723,6 +764,8 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
trust_env (bool, optional): set to True if using proxy to make web requests, for example
|
||||
using http(s)_proxy environment variables. Defaults to False.
|
||||
"""
|
||||
# lxml parses scraped pages far faster than the html.parser default
|
||||
kwargs.setdefault('default_parser', 'lxml')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.trust_env = trust_env
|
||||
|
||||
@@ -785,20 +828,13 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
final_results = []
|
||||
for i, result in enumerate(results):
|
||||
url = urls[i]
|
||||
if parser is None:
|
||||
if url.endswith('.xml'):
|
||||
parser = 'xml'
|
||||
else:
|
||||
parser = self.default_parser
|
||||
self._check_parser(parser)
|
||||
final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs))
|
||||
url_parser = parser
|
||||
if url_parser is None:
|
||||
url_parser = 'xml' if url.endswith('.xml') else self.default_parser
|
||||
self._check_parser(url_parser)
|
||||
final_results.append(BeautifulSoup(result, url_parser, **self.bs_kwargs))
|
||||
return final_results
|
||||
|
||||
async def ascrape_all(self, urls: List[str], parser: Union[str, None] = None) -> List[Any]:
|
||||
"""Async fetch all urls, then return soups for all results."""
|
||||
results = await self.fetch_all(urls)
|
||||
return self._unpack_fetch_results(results, urls, parser=parser)
|
||||
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
"""Lazy load text from the url(s) in web_path with error handling."""
|
||||
for path in self.web_paths:
|
||||
@@ -814,19 +850,20 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
# Log the error and continue with the next URL
|
||||
log.exception(f'Error loading {path}: {e}')
|
||||
|
||||
def _document_from_html(self, html: str, url: str) -> Document:
|
||||
"""Build one Document."""
|
||||
soup = self._unpack_fetch_results([html], [url])[0]
|
||||
return Document(
|
||||
page_content=soup.get_text(**self.bs_get_text_kwargs),
|
||||
metadata=extract_metadata(soup, url),
|
||||
)
|
||||
|
||||
async def alazy_load(self) -> AsyncIterator[Document]:
|
||||
"""Async lazy load text from the url(s) in web_path."""
|
||||
results = await self.ascrape_all(self.web_paths)
|
||||
for path, soup in zip(self.web_paths, results):
|
||||
text = soup.get_text(**self.bs_get_text_kwargs)
|
||||
metadata = {'source': path}
|
||||
if title := soup.find('title'):
|
||||
metadata['title'] = title.get_text()
|
||||
if description := soup.find('meta', attrs={'name': 'description'}):
|
||||
metadata['description'] = description.get('content', 'No description found.')
|
||||
if html := soup.find('html'):
|
||||
metadata['language'] = html.get('lang', 'No language found.')
|
||||
yield Document(page_content=text, metadata=metadata)
|
||||
results = await self.fetch_all(self.web_paths)
|
||||
for path, html in zip(self.web_paths, results):
|
||||
# parsing a large page costs hundreds of ms, keep it off the event loop
|
||||
yield await asyncio.to_thread(self._document_from_html, html, path)
|
||||
|
||||
async def aload(self) -> list[Document]:
|
||||
"""Load data into Document objects."""
|
||||
@@ -838,6 +875,7 @@ def get_web_loader(
|
||||
verify_ssl: bool = True,
|
||||
requests_per_second: int = 2,
|
||||
trust_env: bool = False,
|
||||
loader_config: Optional[dict] = None,
|
||||
):
|
||||
# Check if the URLs are valid
|
||||
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
|
||||
@@ -846,6 +884,16 @@ def get_web_loader(
|
||||
log.warning(f'All provided URLs were blocked or invalid: {urls}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
loader_config = loader_config or {}
|
||||
|
||||
def cfg(key, env_value):
|
||||
# Admin-saved DB value wins; env constant covers keys never saved.
|
||||
value = loader_config.get(key)
|
||||
return env_value if value is None else value
|
||||
|
||||
engine = cfg('web_loader_engine', WEB_LOADER_ENGINE)
|
||||
web_loader_timeout = cfg('web_loader_timeout', WEB_LOADER_TIMEOUT)
|
||||
|
||||
web_loader_args = {
|
||||
'web_paths': safe_urls,
|
||||
'verify_ssl': verify_ssl,
|
||||
@@ -854,13 +902,15 @@ def get_web_loader(
|
||||
'trust_env': trust_env,
|
||||
}
|
||||
|
||||
if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web':
|
||||
WebLoaderClass = None
|
||||
|
||||
if engine == '' or engine == 'safe_web':
|
||||
WebLoaderClass = SafeWebBaseLoader
|
||||
|
||||
request_kwargs = {}
|
||||
if WEB_LOADER_TIMEOUT:
|
||||
if web_loader_timeout:
|
||||
try:
|
||||
timeout_value = float(WEB_LOADER_TIMEOUT)
|
||||
timeout_value = float(web_loader_timeout)
|
||||
except ValueError:
|
||||
timeout_value = None
|
||||
|
||||
@@ -870,42 +920,44 @@ def get_web_loader(
|
||||
if request_kwargs:
|
||||
web_loader_args['requests_kwargs'] = request_kwargs
|
||||
|
||||
if WEB_LOADER_ENGINE == 'playwright':
|
||||
if engine == 'playwright':
|
||||
WebLoaderClass = SafePlaywrightURLLoader
|
||||
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT
|
||||
if PLAYWRIGHT_WS_URL:
|
||||
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL
|
||||
web_loader_args['playwright_timeout'] = cfg('playwright_timeout', PLAYWRIGHT_TIMEOUT)
|
||||
playwright_ws_url = cfg('playwright_ws_url', PLAYWRIGHT_WS_URL)
|
||||
if playwright_ws_url:
|
||||
web_loader_args['playwright_ws_url'] = playwright_ws_url
|
||||
|
||||
if WEB_LOADER_ENGINE == 'firecrawl':
|
||||
if engine == 'firecrawl':
|
||||
WebLoaderClass = SafeFireCrawlLoader
|
||||
web_loader_args['api_key'] = FIRECRAWL_API_KEY
|
||||
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL
|
||||
if FIRECRAWL_TIMEOUT:
|
||||
web_loader_args['api_key'] = cfg('firecrawl_api_key', FIRECRAWL_API_KEY)
|
||||
web_loader_args['api_url'] = cfg('firecrawl_api_url', FIRECRAWL_API_BASE_URL)
|
||||
firecrawl_timeout = cfg('firecrawl_timeout', FIRECRAWL_TIMEOUT)
|
||||
if firecrawl_timeout:
|
||||
try:
|
||||
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT)
|
||||
web_loader_args['timeout'] = int(firecrawl_timeout)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if WEB_LOADER_ENGINE == 'tavily':
|
||||
if engine == 'tavily':
|
||||
WebLoaderClass = SafeTavilyLoader
|
||||
web_loader_args['api_key'] = TAVILY_API_KEY
|
||||
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH
|
||||
web_loader_args['api_key'] = cfg('tavily_api_key', TAVILY_API_KEY)
|
||||
web_loader_args['extract_depth'] = cfg('tavily_extract_depth', TAVILY_EXTRACT_DEPTH)
|
||||
|
||||
if WEB_LOADER_ENGINE == 'microsoft_web_iq':
|
||||
if engine == 'microsoft_web_iq':
|
||||
WebLoaderClass = SafeMicrosoftWebIQLoader
|
||||
web_loader_args['api_base_url'] = MICROSOFT_WEB_IQ_API_BASE_URL
|
||||
web_loader_args['api_key'] = MICROSOFT_WEB_IQ_API_KEY
|
||||
web_loader_args['language'] = MICROSOFT_WEB_IQ_LANGUAGE
|
||||
if WEB_LOADER_TIMEOUT:
|
||||
web_loader_args['api_base_url'] = cfg('microsoft_web_iq_api_base_url', MICROSOFT_WEB_IQ_API_BASE_URL)
|
||||
web_loader_args['api_key'] = cfg('microsoft_web_iq_api_key', MICROSOFT_WEB_IQ_API_KEY)
|
||||
web_loader_args['language'] = cfg('microsoft_web_iq_language', MICROSOFT_WEB_IQ_LANGUAGE)
|
||||
if web_loader_timeout:
|
||||
try:
|
||||
web_loader_args['timeout'] = int(WEB_LOADER_TIMEOUT)
|
||||
web_loader_args['timeout'] = int(web_loader_timeout)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if WEB_LOADER_ENGINE == 'external':
|
||||
if engine == 'external':
|
||||
WebLoaderClass = ExternalWebLoader
|
||||
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL
|
||||
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY
|
||||
web_loader_args['external_url'] = cfg('external_web_loader_url', EXTERNAL_WEB_LOADER_URL)
|
||||
web_loader_args['external_api_key'] = cfg('external_web_loader_api_key', EXTERNAL_WEB_LOADER_API_KEY)
|
||||
|
||||
if WebLoaderClass:
|
||||
web_loader = WebLoaderClass(**web_loader_args)
|
||||
@@ -919,6 +971,6 @@ def get_web_loader(
|
||||
return web_loader
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. '
|
||||
f'Invalid WEB_LOADER_ENGINE: {engine}. '
|
||||
"Please set it to 'safe_web', 'playwright', 'firecrawl', 'tavily', 'external', or 'microsoft_web_iq'."
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
BYPASS_PYDUB_PREPROCESSING,
|
||||
DEVICE_TYPE,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
@@ -678,15 +679,19 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
|
||||
for key, value in payload.items():
|
||||
form_data.add_field(key, str(value))
|
||||
|
||||
with open(file_path, 'rb') as audio_file:
|
||||
form_data.add_field('file', audio_file, filename=filename)
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
form_data.add_field('file', audio_chunks(), filename=filename)
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
if r.status == 200:
|
||||
break
|
||||
|
||||
@@ -823,13 +828,18 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
|
||||
base_url or f'https://{region}.api.cognitive.microsoft.com'
|
||||
) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15'
|
||||
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field('definition', definition)
|
||||
form_data.add_field('audio', open(file_path, 'rb'), filename=filename)
|
||||
|
||||
r = None
|
||||
try:
|
||||
session = await get_session()
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field('definition', definition)
|
||||
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field('audio', audio_chunks(), filename=filename)
|
||||
r = await session.post(
|
||||
url=endpoint,
|
||||
data=form_data,
|
||||
@@ -1002,7 +1012,12 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
|
||||
if language:
|
||||
form_data.add_field('language', language)
|
||||
|
||||
form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type)
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field('file', audio_chunks(), filename=filename, content_type=mime_type)
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
@@ -1076,25 +1091,23 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict]
|
||||
detail=ERROR_MESSAGES.DEFAULT(e, 'Error processing audio file'),
|
||||
)
|
||||
|
||||
results = []
|
||||
try:
|
||||
tasks = [transcription_handler(request, chunk_path, metadata, user) for chunk_path in chunk_paths]
|
||||
for coro in asyncio.as_completed(tasks):
|
||||
try:
|
||||
results.append(await coro)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as transcribe_exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Error transcribing chunk: {transcribe_exc}',
|
||||
)
|
||||
# gather keeps results in chunk order, unlike as_completed
|
||||
results = await asyncio.gather(*tasks)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as transcribe_exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Error transcribing chunk: {transcribe_exc}',
|
||||
)
|
||||
finally:
|
||||
# Clean up only the temporary chunks, never the original file
|
||||
for chunk_path in chunk_paths:
|
||||
if chunk_path != file_path and os.path.isfile(chunk_path):
|
||||
try:
|
||||
os.remove(chunk_path)
|
||||
await asyncio.to_thread(os.remove, chunk_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1208,12 +1221,8 @@ async def transcription(
|
||||
if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)):
|
||||
raise ValueError('Invalid file path detected')
|
||||
|
||||
def _write_upload():
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(contents)
|
||||
|
||||
# Audio uploads can be large; write to disk off the event loop.
|
||||
await asyncio.to_thread(_write_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(contents)
|
||||
|
||||
try:
|
||||
metadata = None
|
||||
|
||||
@@ -9,11 +9,12 @@ import urllib
|
||||
import uuid
|
||||
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp import BasicAuth, ClientSession
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from ldap3 import NONE, Connection, Server, Tls
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from ldap3.utils.dn import parse_dn
|
||||
from open_webui.config import (
|
||||
ENABLE_PASSWORD_AUTH,
|
||||
OAUTH_PROVIDERS,
|
||||
@@ -24,6 +25,9 @@ from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
ENABLE_INITIAL_ADMIN_SIGNUP,
|
||||
ENABLE_OAUTH_TOKEN_EXCHANGE,
|
||||
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
|
||||
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
|
||||
OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS,
|
||||
WEBUI_AUTH,
|
||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
@@ -74,6 +78,7 @@ from open_webui.utils.misc import parse_duration, validate_email_format
|
||||
from open_webui.utils.rate_limit import RateLimiter
|
||||
from open_webui.utils.redis import get_redis_client
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter()
|
||||
@@ -83,6 +88,18 @@ log = logging.getLogger(__name__)
|
||||
# Forgive us our failed attempts, as we forgive those
|
||||
# who exceed their allotted rate against this gate.
|
||||
signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3)
|
||||
# Best-effort throttle only: there is no caller identity before the provider answers,
|
||||
# and deployments may derive request.client from proxy headers.
|
||||
token_exchange_rate_limiter = (
|
||||
RateLimiter(
|
||||
redis_client=get_redis_client(),
|
||||
limit=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
|
||||
window=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
|
||||
)
|
||||
if OAUTH_TOKEN_EXCHANGE_RATE_LIMIT is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
ADMIN_CONFIG_KEYS = {
|
||||
'SHOW_ADMIN_DETAILS': 'auth.admin.show',
|
||||
@@ -103,6 +120,7 @@ ADMIN_CONFIG_KEYS = {
|
||||
'AUTOMATION_MIN_INTERVAL': 'automations.min_interval',
|
||||
'ENABLE_AUTOMATIONS': 'automations.enable',
|
||||
'ENABLE_CHANNELS': 'channels.enable',
|
||||
'CHANNEL_MODEL_RESPONSE_MODE': 'channels.model_response_mode',
|
||||
'ENABLE_CALENDAR': 'calendar.enable',
|
||||
'ENABLE_MEMORIES': 'memories.enable',
|
||||
'ENABLE_MEMORY_SYSTEM_CONTEXT': 'memories.system_context.enable',
|
||||
@@ -128,6 +146,9 @@ LDAP_SERVER_CONFIG_KEYS = {
|
||||
'certificate_path': 'ldap.server.ca_cert_file',
|
||||
'validate_cert': 'ldap.server.validate_cert',
|
||||
'ciphers': 'ldap.server.ciphers',
|
||||
'enable_group_management': 'ldap.group.enable_management',
|
||||
'enable_group_creation': 'ldap.group.enable_creation',
|
||||
'attribute_for_groups': 'ldap.server.attribute_for_groups',
|
||||
}
|
||||
|
||||
|
||||
@@ -398,6 +419,52 @@ async def update_password(
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
|
||||
def _unescape_ldap_dn_value(value: str) -> str:
|
||||
"""Resolve RFC 4514 escapes in a DN value, e.g. ``CN=Sales\\, EMEA`` -> ``Sales, EMEA``.
|
||||
|
||||
Consecutive ``\\XX`` hex escapes encode UTF-8 bytes and are decoded together.
|
||||
"""
|
||||
hexdigits = '0123456789abcdefABCDEF'
|
||||
result = []
|
||||
pos = 0
|
||||
length = len(value)
|
||||
while pos < length:
|
||||
char = value[pos]
|
||||
if char == '\\' and pos + 1 < length:
|
||||
if pos + 2 < length and value[pos + 1] in hexdigits and value[pos + 2] in hexdigits:
|
||||
byte_values = bytearray()
|
||||
while (
|
||||
pos + 2 < length
|
||||
and value[pos] == '\\'
|
||||
and value[pos + 1] in hexdigits
|
||||
and value[pos + 2] in hexdigits
|
||||
):
|
||||
byte_values.append(int(value[pos + 1 : pos + 3], 16))
|
||||
pos += 3
|
||||
result.append(byte_values.decode('utf-8', errors='replace'))
|
||||
else:
|
||||
# Backslash escaping a literal special char, e.g. "\," or "\+".
|
||||
result.append(value[pos + 1])
|
||||
pos += 2
|
||||
else:
|
||||
result.append(char)
|
||||
pos += 1
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def extract_group_cn_from_dn(group_dn: str) -> str | None:
|
||||
"""Return the first CN component of an LDAP group DN, or None.
|
||||
|
||||
Uses ``parse_dn`` so escaped separators inside a value (e.g. a group whose
|
||||
name contains a comma) are handled correctly instead of naively splitting
|
||||
on ``,``.
|
||||
"""
|
||||
for attr_type, attr_value, _ in parse_dn(group_dn):
|
||||
if attr_type.upper() == 'CN':
|
||||
return _unescape_ldap_dn_value(attr_value)
|
||||
return None
|
||||
|
||||
|
||||
############################
|
||||
# LDAP Authentication
|
||||
############################
|
||||
@@ -545,17 +612,10 @@ async def ldap_auth(
|
||||
log.info(f'Processing group DN #{group_idx + 1}: {group_dn}')
|
||||
|
||||
try:
|
||||
group_cn = None
|
||||
|
||||
for item in group_dn.split(','):
|
||||
item = item.strip()
|
||||
if item.upper().startswith('CN='):
|
||||
group_cn = item[3:]
|
||||
break
|
||||
group_cn = extract_group_cn_from_dn(group_dn)
|
||||
|
||||
if group_cn:
|
||||
user_groups.append(group_cn)
|
||||
|
||||
else:
|
||||
log.warning(f'Could not extract CN from group DN: {group_dn}')
|
||||
except Exception as e:
|
||||
@@ -627,9 +687,9 @@ async def ldap_auth(
|
||||
|
||||
if user:
|
||||
if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups:
|
||||
if ENABLE_LDAP_GROUP_CREATION:
|
||||
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
|
||||
try:
|
||||
if ENABLE_LDAP_GROUP_CREATION:
|
||||
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
|
||||
await Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
|
||||
log.info(f'Successfully synced groups for user {user.id}: {user_groups}')
|
||||
except Exception as e:
|
||||
@@ -681,14 +741,18 @@ async def signin(
|
||||
pass
|
||||
|
||||
if not await Users.get_user_by_email(email.lower(), db=db):
|
||||
await signup_handler(
|
||||
request,
|
||||
email,
|
||||
str(uuid.uuid4()),
|
||||
name,
|
||||
db=db,
|
||||
source='trusted_header',
|
||||
)
|
||||
try:
|
||||
await signup_handler(
|
||||
request,
|
||||
email,
|
||||
str(uuid.uuid4()),
|
||||
name,
|
||||
db=db,
|
||||
source='trusted_header',
|
||||
)
|
||||
except IntegrityError:
|
||||
if not await Users.get_user_by_email(email.lower(), db=db):
|
||||
raise
|
||||
|
||||
user = await Auths.authenticate_user_by_email(email, db=db)
|
||||
if user:
|
||||
@@ -1141,6 +1205,7 @@ class AdminConfig(BaseModel):
|
||||
AUTOMATION_MIN_INTERVAL: int | str | None = None
|
||||
ENABLE_AUTOMATIONS: bool
|
||||
ENABLE_CHANNELS: bool
|
||||
CHANNEL_MODEL_RESPONSE_MODE: str = 'thread'
|
||||
ENABLE_CALENDAR: bool
|
||||
ENABLE_MEMORIES: bool
|
||||
ENABLE_MEMORY_SYSTEM_CONTEXT: bool
|
||||
@@ -1164,6 +1229,9 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep
|
||||
if form_data.DEFAULT_USER_ROLE not in ['pending', 'user', 'admin']:
|
||||
updates.pop('ui.default_user_role', None)
|
||||
|
||||
if form_data.CHANNEL_MODEL_RESPONSE_MODE not in ['thread', 'channel']:
|
||||
updates.pop('channels.model_response_mode', None)
|
||||
|
||||
pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$'
|
||||
|
||||
# Check if the input string matches the pattern
|
||||
@@ -1188,6 +1256,9 @@ class LdapServerConfig(BaseModel):
|
||||
certificate_path: str | None = None
|
||||
validate_cert: bool = True
|
||||
ciphers: str | None = 'ALL'
|
||||
enable_group_management: bool = False
|
||||
enable_group_creation: bool = False
|
||||
attribute_for_groups: str = 'memberOf'
|
||||
|
||||
|
||||
@router.get('/admin/config/ldap/server', response_model=LdapServerConfig)
|
||||
@@ -1209,6 +1280,11 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user
|
||||
if not value:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
|
||||
|
||||
# The group attribute is what group management reads from the directory
|
||||
# entry; an empty value would make group sync silently do nothing.
|
||||
if form_data.enable_group_management and not (form_data.attribute_for_groups or '').strip():
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY('attribute_for_groups'))
|
||||
|
||||
updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS)
|
||||
updates['ldap.server.app_dn'] = form_data.app_dn or ''
|
||||
updates['ldap.server.app_password'] = form_data.app_dn_password or ''
|
||||
@@ -1240,6 +1316,7 @@ class OAuthConfigForm(BaseModel):
|
||||
"""All OAuth/OIDC settings exposed to the admin panel."""
|
||||
|
||||
# General OAuth
|
||||
ENABLE_OAUTH: bool | None = None
|
||||
ENABLE_OAUTH_SIGNUP: bool | None = None
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None
|
||||
OAUTH_AUTO_REDIRECT: bool | None = None
|
||||
@@ -1295,6 +1372,7 @@ OAUTH_COMMA_LIST_FIELDS = {
|
||||
|
||||
|
||||
OAUTH_CONFIG_KEYS = {
|
||||
'ENABLE_OAUTH': 'oauth.enable',
|
||||
'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup',
|
||||
'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email',
|
||||
'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect',
|
||||
@@ -1449,6 +1527,37 @@ class TokenExchangeForm(BaseModel):
|
||||
token: str # OAuth access token from external provider
|
||||
|
||||
|
||||
async def get_token_client_id(client, token: str) -> str | None:
|
||||
"""Return the OAuth client_id a token was minted for, when the provider supports introspection."""
|
||||
try:
|
||||
metadata = await client.load_server_metadata()
|
||||
introspection_endpoint = metadata.get('introspection_endpoint')
|
||||
if not introspection_endpoint:
|
||||
log.warning('Token exchange trusted-client check requires an introspection_endpoint')
|
||||
return None
|
||||
|
||||
async with ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
introspection_endpoint,
|
||||
data={'token': token, 'token_type_hint': 'access_token'},
|
||||
auth=BasicAuth(client.client_id, client.client_secret or ''),
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
log.warning(f'Token introspection returned {r.status}')
|
||||
return None
|
||||
introspection = await r.json()
|
||||
|
||||
if not introspection.get('active'):
|
||||
log.warning('Token introspection reports the token is inactive')
|
||||
return None
|
||||
|
||||
return introspection.get('client_id')
|
||||
except Exception as e:
|
||||
log.warning(f'Token introspection failed: {e}')
|
||||
return None
|
||||
|
||||
|
||||
@router.post('/oauth/{provider}/token/exchange', response_model=SessionUserResponse)
|
||||
async def token_exchange(
|
||||
request: Request,
|
||||
@@ -1467,6 +1576,14 @@ async def token_exchange(
|
||||
detail='Token exchange is disabled',
|
||||
)
|
||||
|
||||
if token_exchange_rate_limiter and token_exchange_rate_limiter.is_limited(
|
||||
request.client.host if request.client else 'unknown'
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
|
||||
)
|
||||
|
||||
provider = provider.lower()
|
||||
|
||||
# Check if provider is configured
|
||||
@@ -1484,6 +1601,20 @@ async def token_exchange(
|
||||
detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
|
||||
)
|
||||
|
||||
if OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS:
|
||||
token_client_id = await get_token_client_id(client, form_data.token)
|
||||
if not token_client_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unable to determine which client the token was issued to',
|
||||
)
|
||||
if token_client_id not in OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS:
|
||||
log.warning('Token exchange denied: token was issued to an untrusted client for %s', provider)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Validate the token by calling the userinfo endpoint
|
||||
try:
|
||||
token_data = {'access_token': form_data.token, 'token_type': 'Bearer'}
|
||||
|
||||
@@ -16,6 +16,7 @@ from open_webui.models.automations import (
|
||||
Automations,
|
||||
)
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.automations import (
|
||||
@@ -56,16 +57,11 @@ async def check_automations_permission(request, user):
|
||||
|
||||
|
||||
def check_automation_access(automation, user):
|
||||
if not automation:
|
||||
if not automation or user.id != automation.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
if user.role != 'admin' and user.id != automation.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
async def check_automation_limits(request, user, rrule_str: str, db, is_create: bool = False):
|
||||
@@ -97,6 +93,17 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
|
||||
)
|
||||
|
||||
|
||||
async def check_automation_folder_access(folder_id: Optional[str], user, db: AsyncSession):
|
||||
if folder_id is None:
|
||||
return
|
||||
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id, db=db)
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse:
|
||||
"""Full enrichment for single-item views (includes next_runs computation)."""
|
||||
last_run = await AutomationRuns.get_latest(automation.id, db=db)
|
||||
@@ -117,6 +124,7 @@ async def get_automation_items(
|
||||
request: Request,
|
||||
query: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
folder_id: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
@@ -130,6 +138,7 @@ async def get_automation_items(
|
||||
user_id=user.id,
|
||||
query=query,
|
||||
status=status,
|
||||
folder_id=folder_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
@@ -164,6 +173,7 @@ async def create_new_automation(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_automations_permission(request, user)
|
||||
await check_automation_folder_access(form_data.folder_id, user, db)
|
||||
try:
|
||||
validate_rrule(form_data.data.rrule, tz=user.timezone)
|
||||
except ValueError as e:
|
||||
@@ -182,7 +192,7 @@ async def create_new_automation(
|
||||
EVENTS.AUTOMATION_CREATED,
|
||||
actor=user,
|
||||
subject_id=automation.id,
|
||||
data={'name': automation.name, 'is_active': automation.is_active},
|
||||
data={'name': automation.name, 'is_active': automation.is_active, 'folder_id': automation.folder_id},
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -221,6 +231,7 @@ async def update_automation_by_id(
|
||||
await check_automations_permission(request, user)
|
||||
automation = await Automations.get_by_id(id, db=db)
|
||||
check_automation_access(automation, user)
|
||||
await check_automation_folder_access(form_data.folder_id, user, db)
|
||||
|
||||
try:
|
||||
validate_rrule(form_data.data.rrule, tz=user.timezone)
|
||||
@@ -240,7 +251,7 @@ async def update_automation_by_id(
|
||||
EVENTS.AUTOMATION_UPDATED,
|
||||
actor=user,
|
||||
subject_id=updated.id,
|
||||
data={'name': updated.name, 'is_active': updated.is_active},
|
||||
data={'name': updated.name, 'is_active': updated.is_active, 'folder_id': updated.folder_id},
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ from open_webui.utils.models import (
|
||||
get_all_models,
|
||||
get_filtered_models,
|
||||
)
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -915,7 +914,6 @@ async def get_pinned_channel_messages(
|
||||
|
||||
|
||||
async def send_notification(request, channel, message, active_user_ids, db=None):
|
||||
name = request.app.state.WEBUI_NAME
|
||||
webui_url = await Config.get('webui.url')
|
||||
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
||||
|
||||
@@ -923,23 +921,29 @@ async def send_notification(request, channel, message, active_user_ids, db=None)
|
||||
|
||||
# Batch fetch channel members in 1 query (fixes N+1)
|
||||
member_ids = {m.user_id for m in await Channels.get_members_by_channel_id(channel.id, db=db)}
|
||||
url = f'{webui_url}/channels/{channel.id}'
|
||||
|
||||
for u in users:
|
||||
if (u.id not in active_user_ids) and u.id in member_ids:
|
||||
if enable_user_webhooks and u.settings:
|
||||
webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None)
|
||||
if webhook_url:
|
||||
await post_webhook(
|
||||
name,
|
||||
webhook_url,
|
||||
f'#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}',
|
||||
{
|
||||
'action': 'channel',
|
||||
'message': message.content,
|
||||
'title': channel.name,
|
||||
'url': f'{webui_url}/channels/{channel.id}',
|
||||
},
|
||||
)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHANNEL_MESSAGE,
|
||||
subject_id=channel.id,
|
||||
subject_type='channel',
|
||||
data={
|
||||
'user_id': u.id,
|
||||
'channel_id': channel.id,
|
||||
'message_id': message.id,
|
||||
'sender_id': message.user_id,
|
||||
'content': message.content,
|
||||
'message': f'#{channel.name} - {url}\n\n{message.content}',
|
||||
'content_preview': message.content[:300],
|
||||
'title': channel.name,
|
||||
'url': url,
|
||||
},
|
||||
message=channel.name,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -983,13 +987,20 @@ async def model_response_handler(request, channel, message, user, db=None):
|
||||
db=db,
|
||||
)
|
||||
)[::-1]
|
||||
response_parent_id = (
|
||||
message.parent_id
|
||||
if message.parent_id
|
||||
else (
|
||||
message.id if await Config.get('channels.model_response_mode', 'thread') == 'thread' else None
|
||||
)
|
||||
)
|
||||
|
||||
response_message, channel = await new_message_handler(
|
||||
request,
|
||||
channel.id,
|
||||
MessageForm(
|
||||
**{
|
||||
'parent_id': (message.parent_id if message.parent_id else message.id),
|
||||
'parent_id': response_parent_id,
|
||||
'content': f'',
|
||||
'data': {},
|
||||
'meta': {
|
||||
@@ -1496,12 +1507,13 @@ async def update_message_by_id(
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and message.user_id != user.id
|
||||
and not await channel_has_access(user.id, channel, permission='write', strict=False, db=db)
|
||||
if user.role != 'admin' and not await channel_has_access(
|
||||
user.id, channel, permission='write', strict=False, db=db
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
# Write access is not authorship — block cross-member edits.
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
await Messages.update_message_by_id(message_id, form_data, db=db)
|
||||
@@ -1721,18 +1733,17 @@ async def delete_message_by_id(
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and message.user_id != user.id
|
||||
and not await channel_has_access(
|
||||
user.id,
|
||||
channel,
|
||||
permission='write',
|
||||
strict=False,
|
||||
db=db,
|
||||
)
|
||||
if user.role != 'admin' and not await channel_has_access(
|
||||
user.id,
|
||||
channel,
|
||||
permission='write',
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
# Write access is not authorship — block cross-member deletes.
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
await Messages.delete_message_by_id(message_id, db=db)
|
||||
|
||||
+390
-123
@@ -6,14 +6,16 @@ import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import (
|
||||
AggregateChatStats,
|
||||
ChatBody,
|
||||
@@ -26,6 +28,7 @@ from open_webui.models.chats import (
|
||||
ChatStatsExport,
|
||||
ChatTitleIdResponse,
|
||||
ChatUsageStatsListResponse,
|
||||
is_internal_chat,
|
||||
MessageStats,
|
||||
)
|
||||
from open_webui.models.folders import Folders
|
||||
@@ -35,8 +38,9 @@ from open_webui.socket.main import get_event_emitter
|
||||
from open_webui.tasks import has_active_tasks, stop_item_tasks
|
||||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.context_compaction import compact_chat_branch
|
||||
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
|
||||
from open_webui.utils.chat_fork import build_fork_history
|
||||
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.models import get_all_models
|
||||
from pydantic import BaseModel
|
||||
@@ -49,15 +53,93 @@ router = APIRouter()
|
||||
SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
|
||||
|
||||
CHAT_CONFIG_KEYS = {
|
||||
'CONTEXT_COMPACTION_MODEL': 'chat.context_compaction.model',
|
||||
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
|
||||
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
|
||||
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage',
|
||||
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
|
||||
}
|
||||
|
||||
|
||||
async def get_optional_verified_user(
|
||||
request: Request,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
auth_token: HTTPAuthorizationCredentials | None = Depends(bearer_security),
|
||||
):
|
||||
try:
|
||||
user = await get_current_user(request, response, background_tasks, auth_token)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
if user.role not in {'user', 'admin'}:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def is_open_shared_chat(shared, db: AsyncSession) -> bool:
|
||||
return await AccessGrants.has_anyone_access(
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def can_read_shared_chat(user, shared, db: AsyncSession) -> bool:
|
||||
if user.role == 'pending':
|
||||
return False
|
||||
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
return True
|
||||
if shared.user_id == user.id:
|
||||
return True
|
||||
return await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def add_active_state_to_chat_list(
|
||||
request: Request, chat_list: list[ChatTitleIdResponse]
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
for chat in chat_list:
|
||||
chat.active = False
|
||||
if not await has_active_tasks(request.app.state.redis, chat.id):
|
||||
continue
|
||||
|
||||
chat.active = await ChatMessages.has_unfinished_assistant_by_chat_id(chat.id)
|
||||
|
||||
return chat_list
|
||||
|
||||
|
||||
async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
|
||||
user_folders = await Folders.get_folders_by_user_id(user_id, db=db)
|
||||
parent_by_id = {folder.id: folder.parent_id for folder in user_folders}
|
||||
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
|
||||
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
|
||||
|
||||
for unread_folder_id, unread_count in direct_unread_counts.items():
|
||||
current_id = unread_folder_id
|
||||
seen = set()
|
||||
while current_id and current_id not in seen:
|
||||
seen.add(current_id)
|
||||
if current_id in unread_counts:
|
||||
unread_counts[current_id] += unread_count
|
||||
current_id = parent_by_id.get(current_id)
|
||||
|
||||
return unread_counts
|
||||
|
||||
|
||||
class ChatConfigForm(BaseModel):
|
||||
CONTEXT_COMPACTION_MODEL: str | None = ''
|
||||
ENABLE_CONTEXT_COMPACTION: bool
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
|
||||
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
|
||||
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
|
||||
|
||||
|
||||
@@ -104,7 +186,14 @@ def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) ->
|
||||
|
||||
async def get_chat_config_values() -> dict:
|
||||
values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
|
||||
return {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
|
||||
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
|
||||
if config.get('CONTEXT_COMPACTION_MODEL') is None:
|
||||
config['CONTEXT_COMPACTION_MODEL'] = ''
|
||||
if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None:
|
||||
config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000)
|
||||
if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None:
|
||||
config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40
|
||||
return config
|
||||
|
||||
|
||||
def chat_config_updates(data: dict) -> dict:
|
||||
@@ -131,10 +220,13 @@ async def require_chat_import_permission(request: Request, user, db: AsyncSessio
|
||||
@router.get('/', response_model=list[ChatTitleIdResponse])
|
||||
@router.get('/list', response_model=list[ChatTitleIdResponse])
|
||||
async def get_session_user_chat_list(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
page: int | None = None,
|
||||
include_pinned: bool | None = False,
|
||||
include_folders: bool | None = False,
|
||||
sort_by: str = 'updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
@@ -142,26 +234,42 @@ async def get_session_user_chat_list(
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
return await Chats.get_chat_title_id_list_by_user_id(
|
||||
chats = await Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id,
|
||||
include_folders=include_folders,
|
||||
include_pinned=include_pinned,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
else:
|
||||
return await Chats.get_chat_title_id_list_by_user_id(
|
||||
chats = await Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id,
|
||||
include_folders=include_folders,
|
||||
include_pinned=include_pinned,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
db=db,
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
||||
@router.post('/read')
|
||||
async def mark_chats_read_by_user_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
return {
|
||||
'updated_count': await Chats.mark_chats_read_by_user_id(user.id, db=db),
|
||||
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# GetChatUsageStats
|
||||
# EXPERIMENTAL: may be removed in future releases
|
||||
@@ -599,6 +707,7 @@ async def delete_all_user_chats(
|
||||
|
||||
@router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_chat_list_by_user_id(
|
||||
request: Request,
|
||||
user_id: str,
|
||||
page: int | None = None,
|
||||
query: str | None = None,
|
||||
@@ -623,9 +732,10 @@ async def get_user_chat_list_by_user_id(
|
||||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
return await Chats.get_chat_list_by_user_id(
|
||||
chats = await Chats.get_chat_list_by_user_id(
|
||||
user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
@@ -664,7 +774,7 @@ async def create_new_chat(
|
||||
subject_id=chat.id,
|
||||
data={'title': chat.title, 'folder_id': chat.folder_id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
@@ -712,11 +822,16 @@ async def get_chat_config(user=Depends(get_admin_user)):
|
||||
@router.post('/config', response_model=ChatConfigForm)
|
||||
async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
|
||||
threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
|
||||
token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold))
|
||||
retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE)))
|
||||
await Config.upsert(
|
||||
chat_config_updates(
|
||||
{
|
||||
**form_data.model_dump(),
|
||||
'CONTEXT_COMPACTION_MODEL': form_data.CONTEXT_COMPACTION_MODEL or '',
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
|
||||
'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
|
||||
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -730,6 +845,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user
|
||||
|
||||
@router.get('/search', response_model=list[ChatTitleIdResponse])
|
||||
async def search_user_chats(
|
||||
request: Request,
|
||||
text: str,
|
||||
page: int | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
@@ -744,7 +860,17 @@ async def search_user_chats(
|
||||
search_text = chat_search_content_text(text)
|
||||
chat_list = []
|
||||
for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db):
|
||||
chat_list.append(ChatTitleIdResponse(**chat.model_dump(), snippet=chat_search_snippet(chat.chat, search_text)))
|
||||
# Explicit fields: model_dump() would deep-copy the entire chat blob per row
|
||||
chat_list.append(
|
||||
ChatTitleIdResponse(
|
||||
id=chat.id,
|
||||
title=chat.title,
|
||||
updated_at=chat.updated_at,
|
||||
created_at=chat.created_at,
|
||||
last_read_at=chat.last_read_at,
|
||||
snippet=chat_search_snippet(chat.chat, search_text),
|
||||
)
|
||||
)
|
||||
|
||||
# Delete tag if no chat is found
|
||||
words = text.strip().split(' ')
|
||||
@@ -755,7 +881,7 @@ async def search_user_chats(
|
||||
log.debug(f'deleting tag: {tag_id}')
|
||||
await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
|
||||
|
||||
return chat_list
|
||||
return await add_active_state_to_chat_list(request, chat_list)
|
||||
|
||||
|
||||
############################
|
||||
@@ -773,15 +899,18 @@ async def get_chats_by_folder_id(
|
||||
folder_ids.extend([folder.id for folder in children_folders])
|
||||
|
||||
return [
|
||||
ChatResponse(**chat.model_dump())
|
||||
ChatResponse.model_validate(chat, from_attributes=True)
|
||||
for chat in await Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id, db=db)
|
||||
]
|
||||
|
||||
|
||||
@router.get('/folder/{folder_id}/list')
|
||||
@router.get('/folder/{folder_id}/list', response_model=list[ChatTitleIdResponse])
|
||||
async def get_chat_list_by_folder_id(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
page: int | None = 1,
|
||||
sort_by: str = 'unread_updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
@@ -789,11 +918,16 @@ async def get_chat_list_by_folder_id(
|
||||
limit = 10
|
||||
skip = (page - 1) * limit
|
||||
|
||||
chats = await Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
|
||||
return [
|
||||
{'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at}
|
||||
for chat in chats
|
||||
]
|
||||
chats = await Chats.get_chats_by_folder_id_and_user_id(
|
||||
folder_id,
|
||||
user.id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
db=db,
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -806,8 +940,11 @@ async def get_chat_list_by_folder_id(
|
||||
|
||||
|
||||
@router.get('/pinned', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
return await Chats.get_pinned_chats_by_user_id(user.id, db=db)
|
||||
async def get_user_pinned_chats(
|
||||
request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
chats = await Chats.get_pinned_chats_by_user_id(user.id, db=db)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
@@ -838,7 +975,7 @@ async def generate_chat_export_ndjson(user_id: str):
|
||||
|
||||
for chat in result.items:
|
||||
try:
|
||||
yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n'
|
||||
yield ChatResponse.model_validate(chat, from_attributes=True).model_dump_json() + '\n'
|
||||
except Exception as e:
|
||||
log.exception(f'Error serializing chat {chat.id}: {e}')
|
||||
|
||||
@@ -863,7 +1000,10 @@ async def get_user_chats(user=Depends(get_verified_user)):
|
||||
|
||||
@router.get('/all/archived', response_model=list[ChatResponse])
|
||||
async def get_user_archived_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)]
|
||||
return [
|
||||
ChatResponse.model_validate(chat, from_attributes=True)
|
||||
for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)
|
||||
]
|
||||
|
||||
|
||||
############################
|
||||
@@ -890,7 +1030,7 @@ async def get_all_user_tags(user=Depends(get_verified_user), db: AsyncSession =
|
||||
async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_ADMIN_EXPORT:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_chats(db=db)]
|
||||
return [ChatResponse.model_validate(chat, from_attributes=True) for chat in await Chats.get_chats(db=db)]
|
||||
|
||||
|
||||
############################
|
||||
@@ -900,6 +1040,7 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSessio
|
||||
|
||||
@router.get('/archived', response_model=list[ChatTitleIdResponse])
|
||||
async def get_archived_session_user_chat_list(
|
||||
request: Request,
|
||||
page: int | None = None,
|
||||
query: str | None = None,
|
||||
order_by: str | None = None,
|
||||
@@ -921,13 +1062,14 @@ async def get_archived_session_user_chat_list(
|
||||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
return await Chats.get_archived_chat_list_by_user_id(
|
||||
chats = await Chats.get_archived_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
@@ -1045,38 +1187,30 @@ async def get_shared_session_user_chat_list(
|
||||
|
||||
@router.get('/share/{share_id}', response_model=ChatResponse | None)
|
||||
async def get_shared_chat_by_id(
|
||||
share_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
share_id: str, user=Depends(get_optional_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
if user.role == 'pending':
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
shared = await SharedChats.get_by_id(share_id, db=db)
|
||||
if shared:
|
||||
if await is_open_shared_chat(shared, db=db) or (
|
||||
user is not None and await can_read_shared_chat(user, shared, db=db)
|
||||
):
|
||||
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
||||
if chat:
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED if user else ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
# Fallback: admins can also access any chat directly by chat ID
|
||||
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = None
|
||||
if user is not None and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(share_id, db=db)
|
||||
if chat:
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
# Look up the original chat_id to check access grants (admins bypass)
|
||||
if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS:
|
||||
shared = await SharedChats.get_by_id(share_id, db=db)
|
||||
if shared and shared.user_id != user.id:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if not has_grant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
|
||||
############################
|
||||
@@ -1095,6 +1229,7 @@ class TagFilterForm(TagForm):
|
||||
|
||||
@router.post('/tags', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_chat_list_by_tag_name(
|
||||
request: Request,
|
||||
form_data: TagFilterForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
@@ -1105,7 +1240,7 @@ async def get_user_chat_list_by_tag_name(
|
||||
if len(chats) == 0:
|
||||
await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db)
|
||||
|
||||
return chats
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
@@ -1136,7 +1271,8 @@ async def compact_chat_by_id(
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id)
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId'))
|
||||
current_message_id = chat.current_message_id or history.get('currentId')
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id)
|
||||
model_id = (form_data.model if form_data else None) or next(
|
||||
(message.get('model') for message in reversed(message_list) if message.get('model')),
|
||||
None,
|
||||
@@ -1149,6 +1285,7 @@ async def compact_chat_by_id(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.')
|
||||
|
||||
result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS)
|
||||
result['context_usage'] = await get_chat_context_usage(chat, model_id)
|
||||
if result.get('compacted'):
|
||||
await publish_event(
|
||||
request,
|
||||
@@ -1169,31 +1306,36 @@ async def compact_chat_by_id(
|
||||
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
|
||||
if not chat:
|
||||
# Check if user has access via access grants (shared_chat grants)
|
||||
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
else:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
if not chat and user.role == 'admin':
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
|
||||
chat = candidate
|
||||
|
||||
# Check folder-based access (shared folders)
|
||||
if not chat:
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if candidate and candidate.folder_id:
|
||||
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
chat = candidate
|
||||
# Access explicitly granted to this user applies to admins too, so an admin
|
||||
# does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off.
|
||||
if not chat:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
|
||||
# Check folder-based access (shared folders)
|
||||
if not chat:
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if candidate and candidate.folder_id:
|
||||
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
chat = candidate
|
||||
|
||||
if chat:
|
||||
return ChatResponse(**chat.model_dump())
|
||||
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
|
||||
data['context_usage'] = await get_chat_context_usage(chat)
|
||||
return data
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
@@ -1220,7 +1362,18 @@ async def update_chat_by_id(
|
||||
form_data.chat.get('history'),
|
||||
)
|
||||
|
||||
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
|
||||
touch = 'history' in form_data.chat or 'messages' in form_data.chat
|
||||
chat = await Chats.update_chat_by_id(id, updated_chat, db=db, touch=touch)
|
||||
if form_data.variables is not None:
|
||||
chat = (
|
||||
await Chats.update_chat_variables_by_id(
|
||||
id,
|
||||
form_data.variables,
|
||||
db=db,
|
||||
touch=False,
|
||||
)
|
||||
or chat
|
||||
)
|
||||
|
||||
# Reconcile chat_message rows without inferring deletes from missing IDs.
|
||||
# Message deletion has its own endpoint below.
|
||||
@@ -1235,7 +1388,7 @@ async def update_chat_by_id(
|
||||
subject_id=id,
|
||||
data={'title': chat.title},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -1309,7 +1462,7 @@ async def update_chat_message_by_id(
|
||||
subject_id=message_id,
|
||||
data={'chat_id': id, 'content_preview': form_data.content[:300]},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete('/{id}/messages/{message_id}', response_model=ChatResponse | None)
|
||||
@@ -1348,7 +1501,7 @@ async def delete_chat_message_by_id(
|
||||
subject_id=message_id,
|
||||
data={'chat_id': id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
############################
|
||||
@@ -1419,55 +1572,48 @@ async def delete_chat_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
# Cancel any in-flight LLM tasks (streaming, title/tags generation)
|
||||
# before deleting the chat to prevent orphaned requests.
|
||||
await stop_item_tasks(request.app.state.redis, id)
|
||||
|
||||
# Authorize before any side effect: cancelling a chat's in-flight tasks must
|
||||
# not be reachable for a chat the caller may not delete.
|
||||
if user.role == 'admin':
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
|
||||
|
||||
result = await Chats.delete_chat_by_id(id, db=db)
|
||||
|
||||
if result:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_DELETED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data={'owner_id': chat.user_id},
|
||||
)
|
||||
return result
|
||||
else:
|
||||
if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Cancel any in-flight LLM tasks (streaming, title/tags generation) before
|
||||
# deleting the chat to prevent orphaned requests.
|
||||
await stop_item_tasks(request.app.state.redis, id)
|
||||
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
|
||||
|
||||
# Cascade to internal child chats spawned from this one.
|
||||
for child_id in await Chats.get_internal_chat_ids_by_parent_id(id, chat.user_id):
|
||||
await stop_item_tasks(request.app.state.redis, child_id)
|
||||
await Chats.delete_chat_by_id_and_user_id(child_id, chat.user_id)
|
||||
|
||||
if user.role == 'admin':
|
||||
result = await Chats.delete_chat_by_id(id, db=db)
|
||||
else:
|
||||
result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if result:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_DELETED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data={'owner_id': user.id},
|
||||
)
|
||||
return result
|
||||
|
||||
if result:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_DELETED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data={'owner_id': chat.user_id},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
############################
|
||||
@@ -1519,6 +1665,101 @@ class CloneForm(BaseModel):
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class ForkForm(BaseModel):
|
||||
message_id: str | None = None
|
||||
|
||||
|
||||
@router.post('/{id}/fork', response_model=ChatResponse | None)
|
||||
async def fork_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ForkForm | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await require_chat_import_permission(request, user, db)
|
||||
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if await has_active_tasks(request.app.state.redis, id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before forking.',
|
||||
)
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
|
||||
if any(
|
||||
message.get('role') == 'assistant' and message.get('done') is False
|
||||
for message in messages_map.values()
|
||||
if isinstance(message, dict)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before forking.',
|
||||
)
|
||||
|
||||
source_message_id = (
|
||||
(form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
|
||||
)
|
||||
if not source_message_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork')
|
||||
|
||||
try:
|
||||
fork_history, fork_messages = build_fork_history(messages_map, source_message_id)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST,
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
updated_chat = {**(chat.chat or {})}
|
||||
updated_chat.pop('currentId', None)
|
||||
updated_chat.update(
|
||||
{
|
||||
'originalChatId': chat.id,
|
||||
'branchPointMessageId': source_message_id,
|
||||
'title': f'{chat.title} (fork)',
|
||||
'history': fork_history,
|
||||
'messages': fork_messages,
|
||||
}
|
||||
)
|
||||
meta = {
|
||||
**(chat.meta or {}),
|
||||
'forked_from': chat.id,
|
||||
'forked_from_message_id': source_message_id,
|
||||
}
|
||||
|
||||
fork = await Chats.insert_new_chat(
|
||||
str(uuid4()),
|
||||
user.id,
|
||||
ChatForm(chat=updated_chat, folder_id=chat.folder_id),
|
||||
db=db,
|
||||
internal_meta=meta,
|
||||
)
|
||||
|
||||
if fork and chat.variables:
|
||||
fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork
|
||||
|
||||
if not fork:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if chat.pinned:
|
||||
fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_CLONED,
|
||||
actor=user,
|
||||
subject_id=fork.id,
|
||||
data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
|
||||
)
|
||||
return ChatResponse.model_validate(fork, from_attributes=True)
|
||||
|
||||
|
||||
@router.post('/{id}/clone', response_model=ChatResponse | None)
|
||||
async def clone_chat_by_id(
|
||||
request: Request,
|
||||
@@ -1545,6 +1786,7 @@ async def clone_chat_by_id(
|
||||
**{
|
||||
'chat': updated_chat,
|
||||
'meta': chat.meta,
|
||||
'variables': chat.variables or {},
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
}
|
||||
@@ -1562,7 +1804,7 @@ async def clone_chat_by_id(
|
||||
subject_id=chat.id,
|
||||
data={'original_chat_id': id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -1601,7 +1843,7 @@ async def clone_shared_chat_by_id(
|
||||
# Enforce access grants (owner and admins bypass)
|
||||
shared = await SharedChats.get_by_id(id, db=db)
|
||||
if shared and user.role != 'admin' and shared.user_id != user.id:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
has_grant = await is_open_shared_chat(shared, db=db) or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
@@ -1628,6 +1870,7 @@ async def clone_shared_chat_by_id(
|
||||
**{
|
||||
'chat': updated_chat,
|
||||
'meta': chat.meta,
|
||||
'variables': chat.variables or {},
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
}
|
||||
@@ -1638,7 +1881,7 @@ async def clone_shared_chat_by_id(
|
||||
|
||||
if chats:
|
||||
chat = chats[0]
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -1679,7 +1922,7 @@ async def archive_chat_by_id(
|
||||
subject_id=id,
|
||||
subject_type='chat',
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
@@ -1713,7 +1956,7 @@ async def share_chat_by_id(
|
||||
subject_id=id,
|
||||
data={'share_id': chat.share_id, 'updated': True},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
# Create a new share
|
||||
shared = await SharedChats.create(id, user.id, db=db)
|
||||
@@ -1731,7 +1974,7 @@ async def share_chat_by_id(
|
||||
subject_id=id,
|
||||
data={'share_id': shared.id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
# --- Delete Shared Chat ---
|
||||
@@ -1795,11 +2038,13 @@ async def update_shared_chat_access_by_id(
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_chats',
|
||||
'sharing.open_chats',
|
||||
db=db,
|
||||
)
|
||||
|
||||
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
|
||||
|
||||
############################
|
||||
@@ -1844,6 +2089,28 @@ class ChatFolderIdForm(BaseModel):
|
||||
folder_id: str | None = None
|
||||
|
||||
|
||||
@router.post('/{id}/unread')
|
||||
async def mark_chat_unread_by_id(
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.mark_chat_unread_by_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
folder_id = await Chats.get_chat_folder_id(id, user.id, db=db)
|
||||
return {
|
||||
'chat_id': id,
|
||||
'last_read_at': chat.last_read_at,
|
||||
'folder_id': folder_id,
|
||||
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
|
||||
}
|
||||
|
||||
|
||||
@router.post('/{id}/folder', response_model=ChatResponse | None)
|
||||
async def update_chat_folder_id_by_id(
|
||||
request: Request,
|
||||
@@ -1874,7 +2141,7 @@ async def update_chat_folder_id_by_id(
|
||||
subject_id=id,
|
||||
data={'folder_id': form_data.folder_id},
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
return ChatResponse.model_validate(chat, from_attributes=True)
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
||||
@@ -67,6 +67,15 @@ MODELS_CONFIG_KEYS = {
|
||||
'DEFAULT_MODEL_METADATA': 'models.default_metadata',
|
||||
'DEFAULT_MODEL_PARAMS': 'models.default_params',
|
||||
}
|
||||
SUBAGENTS_CONFIG_KEYS = {
|
||||
'ENABLE_SUBAGENTS': 'subagents.enable',
|
||||
'SUBAGENTS_BACKGROUND_ENABLED': 'subagents.background_enabled',
|
||||
'SUBAGENTS_MAX_CONCURRENT': 'subagents.max_concurrent',
|
||||
'SUBAGENTS_MAX_ASYNC': 'subagents.max_async',
|
||||
'SUBAGENTS_MAX_ITERATIONS': 'subagents.max_iterations',
|
||||
'SUBAGENTS_MAX_OUTPUT': 'subagents.max_output',
|
||||
'SUBAGENTS_SYSTEM_PROMPT': 'subagents.system_prompt',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
@@ -196,7 +205,7 @@ async def register_oauth_client(
|
||||
log.debug(f'Failed to register OAuth client: {e}')
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Failed to register OAuth client',
|
||||
detail=f'Failed to register OAuth client: {e}',
|
||||
)
|
||||
|
||||
|
||||
@@ -298,10 +307,8 @@ class TerminalServerConnection(BaseModel):
|
||||
|
||||
config: dict | None = None
|
||||
|
||||
# Orchestrator policy fields
|
||||
server_type: str | None = None # "orchestrator", "terminal"
|
||||
server_type: str | None = None
|
||||
policy_id: str | None = None
|
||||
policy: dict | None = None # cached policy data
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
@@ -321,7 +328,9 @@ async def set_terminal_servers_config(
|
||||
form_data: TerminalServersConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS]
|
||||
connections = [
|
||||
connection.model_dump(exclude={'policy', 'lifecycle'}) for connection in form_data.TERMINAL_SERVER_CONNECTIONS
|
||||
]
|
||||
await Config.upsert({'terminal_server.connections': connections})
|
||||
|
||||
await set_terminal_servers(request)
|
||||
@@ -391,7 +400,7 @@ class TerminalServerPolicyForm(BaseModel):
|
||||
key: str | None = ''
|
||||
auth_type: str | None = 'bearer'
|
||||
policy_id: str
|
||||
policy_data: dict
|
||||
policy_data: dict | None = None
|
||||
|
||||
|
||||
class TerminalServerLifecycleForm(BaseModel):
|
||||
@@ -399,7 +408,7 @@ class TerminalServerLifecycleForm(BaseModel):
|
||||
key: str | None = ''
|
||||
auth_type: str | None = 'bearer'
|
||||
policy_id: str
|
||||
lifecycle_data: dict
|
||||
lifecycle_data: dict | None = None
|
||||
|
||||
|
||||
class TerminalServerRefreshForm(BaseModel):
|
||||
@@ -416,9 +425,7 @@ class TerminalServerRefreshForm(BaseModel):
|
||||
async def put_terminal_server_policy(
|
||||
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Proxy a policy PUT to an orchestrator terminal server.
|
||||
"""
|
||||
"""Proxy a policy read or update to an orchestrator terminal server."""
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
@@ -433,8 +440,12 @@ async def put_terminal_server_policy(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as session:
|
||||
policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}'
|
||||
async with session.put(
|
||||
policy_url, headers=headers, json=form_data.policy_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
async with session.request(
|
||||
'GET' if form_data.policy_data is None else 'PUT',
|
||||
policy_url,
|
||||
headers=headers,
|
||||
json=form_data.policy_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as resp:
|
||||
if resp.ok:
|
||||
return await resp.json()
|
||||
@@ -443,17 +454,15 @@ async def put_terminal_server_policy(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to save policy to terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to save policy to terminal server')
|
||||
log.debug(f'Failed to access policy on terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to access policy on terminal server')
|
||||
|
||||
|
||||
@router.post('/terminal_servers/lifecycle')
|
||||
async def put_terminal_server_lifecycle(
|
||||
request: Request, form_data: TerminalServerLifecycleForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Proxy a policy lifecycle PUT to an orchestrator terminal server.
|
||||
"""
|
||||
"""Proxy a lifecycle read or update to an orchestrator terminal server."""
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
@@ -468,7 +477,8 @@ async def put_terminal_server_lifecycle(
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as session:
|
||||
lifecycle_url = f'{base_url}/api/v1/policies/{form_data.policy_id}/lifecycle'
|
||||
async with session.put(
|
||||
async with session.request(
|
||||
'GET' if form_data.lifecycle_data is None else 'PUT',
|
||||
lifecycle_url,
|
||||
headers=headers,
|
||||
json=form_data.lifecycle_data,
|
||||
@@ -481,8 +491,8 @@ async def put_terminal_server_lifecycle(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to save lifecycle to terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to save lifecycle to terminal server')
|
||||
log.debug(f'Failed to access lifecycle on terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to access lifecycle on terminal server')
|
||||
|
||||
|
||||
@router.post('/terminal_servers/refresh')
|
||||
@@ -604,7 +614,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
||||
if form_data.headers and isinstance(form_data.headers, dict):
|
||||
if headers is None:
|
||||
headers = {}
|
||||
custom_headers = get_custom_headers(form_data.headers, user)
|
||||
custom_headers = await get_custom_headers(form_data.headers, user)
|
||||
headers.update(custom_headers)
|
||||
|
||||
await client.connect(form_data.url, headers=headers)
|
||||
@@ -649,7 +659,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
||||
if form_data.headers and isinstance(form_data.headers, dict):
|
||||
if headers is None:
|
||||
headers = {}
|
||||
custom_headers = get_custom_headers(form_data.headers, user)
|
||||
custom_headers = await get_custom_headers(form_data.headers, user)
|
||||
headers.update(custom_headers)
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
@@ -754,6 +764,40 @@ async def set_models_config(request: Request, form_data: ModelsConfigForm, user=
|
||||
return values
|
||||
|
||||
|
||||
class SubagentsConfigForm(BaseModel):
|
||||
ENABLE_SUBAGENTS: bool
|
||||
SUBAGENTS_BACKGROUND_ENABLED: bool
|
||||
SUBAGENTS_MAX_CONCURRENT: int
|
||||
SUBAGENTS_MAX_ASYNC: int
|
||||
SUBAGENTS_MAX_ITERATIONS: int
|
||||
SUBAGENTS_MAX_OUTPUT: int
|
||||
SUBAGENTS_SYSTEM_PROMPT: str
|
||||
|
||||
|
||||
@router.get('/subagents', response_model=SubagentsConfigForm)
|
||||
async def get_subagents_config(user=Depends(get_admin_user)):
|
||||
return await get_config_values(SUBAGENTS_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/subagents', response_model=SubagentsConfigForm)
|
||||
async def set_subagents_config(
|
||||
request: Request,
|
||||
form_data: SubagentsConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
await Config.upsert(config_updates(form_data.model_dump(), SUBAGENTS_CONFIG_KEYS))
|
||||
values = await get_config_values(SUBAGENTS_CONFIG_KEYS)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CONFIG_UPDATED,
|
||||
actor=user,
|
||||
subject_id='subagents',
|
||||
subject_type='config',
|
||||
data={'enabled': values.get('ENABLE_SUBAGENTS')},
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
class PromptSuggestion(BaseModel):
|
||||
title: list[str]
|
||||
content: str
|
||||
|
||||
@@ -424,7 +424,7 @@ async def create_feedback(
|
||||
EVENTS.FEEDBACK_CREATED,
|
||||
actor=user,
|
||||
subject_id=feedback.id,
|
||||
data={'rating': getattr(feedback, 'rating', None)},
|
||||
data={'rating': (feedback.data or {}).get('rating')},
|
||||
)
|
||||
return feedback
|
||||
|
||||
@@ -463,7 +463,7 @@ async def update_feedback_by_id(
|
||||
EVENTS.FEEDBACK_UPDATED,
|
||||
actor=user,
|
||||
subject_id=feedback.id,
|
||||
data={'rating': getattr(feedback, 'rating', None)},
|
||||
data={'rating': (feedback.data or {}).get('rating')},
|
||||
)
|
||||
return feedback
|
||||
|
||||
|
||||
@@ -108,6 +108,23 @@ def _cleanup_local_cache(file_path: str) -> None:
|
||||
log.warning(f'Failed to clean up local cache for {file_path}: {e}')
|
||||
|
||||
|
||||
def _matches_configured_mime_type(supported: list[str] | str, content_type: str) -> bool:
|
||||
if isinstance(supported, str):
|
||||
supported = supported.split(',')
|
||||
supported = [item.strip() for item in (supported or []) if item.strip()]
|
||||
if not supported:
|
||||
return False
|
||||
return bool(strict_match_mime_type(supported, content_type))
|
||||
|
||||
|
||||
def _media_supported_for_extraction(
|
||||
content_extraction_engine: str | None, supported: list[str] | str | None, content_type: str
|
||||
) -> bool:
|
||||
if supported is None:
|
||||
return content_extraction_engine == 'external'
|
||||
return bool(content_extraction_engine and _matches_configured_mime_type(supported, content_type))
|
||||
|
||||
|
||||
async def process_uploaded_file(
|
||||
request,
|
||||
file,
|
||||
@@ -127,6 +144,10 @@ async def process_uploaded_file(
|
||||
content_type = 'text/plain'
|
||||
|
||||
stt_supported = await Config.get('audio.stt.supported_content_types', [])
|
||||
content_extraction_engine = await Config.get('rag.content_extraction_engine')
|
||||
content_extraction_supported_media_mime_types = await Config.get(
|
||||
'rag.content_extraction.supported_media_mime_types'
|
||||
)
|
||||
|
||||
if content_type and strict_match_mime_type(stt_supported, content_type):
|
||||
# Audio / STT-supported files → transcribe then index
|
||||
@@ -147,9 +168,10 @@ async def process_uploaded_file(
|
||||
elif (
|
||||
content_type
|
||||
and content_type.startswith(('image/', 'video/'))
|
||||
and await Config.get('rag.content_extraction_engine') != 'external'
|
||||
and not _media_supported_for_extraction(
|
||||
content_extraction_engine, content_extraction_supported_media_mime_types, content_type
|
||||
)
|
||||
):
|
||||
# Media files without an external extraction engine
|
||||
if content_type.startswith('video/'):
|
||||
# Videos are stored as-is for downstream multimodal
|
||||
# processing (Tools, vision models). Attempting text
|
||||
@@ -165,7 +187,8 @@ async def process_uploaded_file(
|
||||
raise Exception(f'File type {content_type} is not supported for processing')
|
||||
|
||||
else:
|
||||
# Documents, or any file when an external engine is configured
|
||||
# Documents, or media files explicitly enabled for the
|
||||
# configured content extraction engine.
|
||||
if not content_type:
|
||||
log.info(f'File type {file.content_type} is not provided, but trying to process anyway')
|
||||
await process_file(
|
||||
@@ -201,21 +224,28 @@ async def process_uploaded_file(
|
||||
f'{knowledge_id}: user {user.id} lacks write access'
|
||||
)
|
||||
else:
|
||||
await Knowledges.add_file_to_knowledge_by_id(
|
||||
knowledge_id=knowledge_id,
|
||||
file_id=file_item.id,
|
||||
user_id=user.id,
|
||||
directory_id=file_metadata.get('directory_id'),
|
||||
)
|
||||
# Keep the generic file status stream open until the
|
||||
# KB-specific vector write and durable link both finish.
|
||||
await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session)
|
||||
await process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
|
||||
user=user,
|
||||
db=db_session,
|
||||
)
|
||||
knowledge_file = await Knowledges.add_file_to_knowledge_by_id(
|
||||
knowledge_id=knowledge_id,
|
||||
file_id=file_item.id,
|
||||
user_id=user.id,
|
||||
directory_id=file_metadata.get('directory_id'),
|
||||
db=db_session,
|
||||
)
|
||||
if not knowledge_file:
|
||||
raise Exception(f'Failed to link file {file_item.id} to knowledge {knowledge_id}')
|
||||
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}')
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'Error processing file: {file_item.id}')
|
||||
|
||||
@@ -6,12 +6,13 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from open_webui.config import UPLOAD_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.folders import (
|
||||
@@ -22,14 +23,16 @@ from open_webui.models.folders import (
|
||||
FolderUpdateForm,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.automations import Automations
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control import (
|
||||
filter_allowed_access_grants,
|
||||
)
|
||||
from open_webui.utils.access_control.files import get_accessible_folder_files
|
||||
from open_webui.utils.access_control.files import can_read_all_folder_files, get_accessible_folder_files
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.tasks import has_active_tasks
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -42,6 +45,24 @@ router = APIRouter()
|
||||
from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access
|
||||
|
||||
|
||||
async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]:
|
||||
folders = await Folders.get_folders_by_user_id(user_id, db=db)
|
||||
parent_by_id = {folder.id: folder.parent_id for folder in folders}
|
||||
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
|
||||
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db)
|
||||
|
||||
for unread_folder_id, unread_count in direct_unread_counts.items():
|
||||
current_id = unread_folder_id
|
||||
seen = set()
|
||||
while current_id and current_id not in seen:
|
||||
seen.add(current_id)
|
||||
if current_id in unread_counts:
|
||||
unread_counts[current_id] += unread_count
|
||||
current_id = parent_by_id.get(current_id)
|
||||
|
||||
return unread_counts
|
||||
|
||||
|
||||
async def check_folders_permission(request: Request, user, db=None):
|
||||
"""Verify the folders feature is enabled and the user has permission."""
|
||||
config = await Config.get_many('folders.enable', 'user.permissions')
|
||||
@@ -76,11 +97,12 @@ async def get_folders(
|
||||
await check_folders_permission(request, user, db=db)
|
||||
|
||||
folders = await Folders.get_folders_by_user_id(user.id, db=db)
|
||||
folder_ids = {folder.id for folder in folders}
|
||||
|
||||
# Verify folder data integrity
|
||||
folder_list = []
|
||||
for folder in folders:
|
||||
if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db):
|
||||
if folder.parent_id and folder.parent_id not in folder_ids:
|
||||
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
|
||||
|
||||
if folder.data and 'files' in folder.data:
|
||||
@@ -91,9 +113,14 @@ async def get_folders(
|
||||
folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
|
||||
)
|
||||
|
||||
folder_list.append(FolderNameIdResponse(**folder.model_dump()))
|
||||
folder_list.append(folder)
|
||||
|
||||
return folder_list
|
||||
unread_counts = await get_folder_unread_counts(user.id, db=db)
|
||||
|
||||
return [
|
||||
FolderNameIdResponse(**folder.model_dump(), unread_count=unread_counts.get(folder.id, 0))
|
||||
for folder in folder_list
|
||||
]
|
||||
|
||||
|
||||
############################
|
||||
@@ -129,6 +156,18 @@ async def create_folder(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
if form_data.data and 'files' in form_data.data:
|
||||
owner = await Users.get_user_by_id(parent.user_id, db=db)
|
||||
if not owner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
if not await can_read_all_folder_files(form_data.data['files'], owner, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
# Create as the folder owner's subfolder (keep tree consistent)
|
||||
try:
|
||||
folder = await Folders.insert_new_folder(parent.user_id, form_data, form_data.parent_id, db=db)
|
||||
@@ -147,6 +186,16 @@ async def create_folder(
|
||||
detail=ERROR_MESSAGES.DEFAULT('Error creating folder'),
|
||||
)
|
||||
|
||||
if (
|
||||
form_data.data
|
||||
and 'files' in form_data.data
|
||||
and not await can_read_all_folder_files(form_data.data['files'], user, db=db)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
try:
|
||||
folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db)
|
||||
await publish_event(
|
||||
@@ -288,11 +337,14 @@ async def update_folder_name_by_id(
|
||||
detail=ERROR_MESSAGES.DEFAULT('Folder already exists'),
|
||||
)
|
||||
|
||||
# Validate read access to every file/collection being attached.
|
||||
# Folder files are consumed by chat middleware as RAG context.
|
||||
if form_data.data and isinstance(form_data.data.get('files'), list):
|
||||
accessible_files = await get_accessible_folder_files(form_data.data['files'], user, db=db)
|
||||
if len(accessible_files) != len(form_data.data['files']):
|
||||
if form_data.data and 'files' in form_data.data:
|
||||
owner = user if folder.user_id == user.id else await Users.get_user_by_id(folder.user_id, db=db)
|
||||
if not owner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
if not await can_read_all_folder_files(form_data.data['files'], owner, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
@@ -390,6 +442,11 @@ async def update_folder_is_expanded_by_id(
|
||||
):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
if not folder:
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if folder and (user.role == 'admin' or await _has_folder_access(user.id, folder, 'read', db)):
|
||||
return folder
|
||||
|
||||
if folder:
|
||||
try:
|
||||
folder = await Folders.update_folder_is_expanded_by_id_and_user_id(
|
||||
@@ -477,6 +534,9 @@ async def update_folder_access_by_id(
|
||||
async def get_shared_folder_chats(
|
||||
request: Request,
|
||||
id: str,
|
||||
page: int | None = Query(None, ge=1),
|
||||
sort_by: str = Query('unread_updated_at'),
|
||||
sort_dir: str = Query('desc'),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
@@ -500,7 +560,18 @@ async def get_shared_folder_chats(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
|
||||
limit = 10
|
||||
skip = (page - 1) * limit if page is not None else 0
|
||||
chats = await Chats.get_all_chats_by_folder_id(
|
||||
id,
|
||||
skip=skip,
|
||||
limit=limit if page is not None else 60,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
unread_for_user_id=user.id,
|
||||
db=db,
|
||||
)
|
||||
total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats)
|
||||
|
||||
# Resolve owner names for display (avatar URLs are constructed client-side)
|
||||
owner_cache: dict[str, str] = {}
|
||||
@@ -510,11 +581,57 @@ async def get_shared_folder_chats(
|
||||
u = await Users.get_user_by_id(uid, db=db)
|
||||
owner_cache[uid] = u.name if u else 'Unknown'
|
||||
chat['owner_name'] = owner_cache[uid]
|
||||
chat['active'] = False
|
||||
if chat['user_id'] != user.id:
|
||||
chat['last_read_at'] = chat['updated_at']
|
||||
if await has_active_tasks(request.app.state.redis, chat['id']):
|
||||
chat['active'] = await ChatMessages.has_unfinished_assistant_by_chat_id(chat['id'], db=db)
|
||||
|
||||
return {
|
||||
response = {
|
||||
'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats],
|
||||
'folder_permission': 'write' if has_write else 'read',
|
||||
}
|
||||
if page is not None:
|
||||
response.update({'total': total, 'has_more': skip + limit < total})
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/{id}/read')
|
||||
async def mark_folder_chats_read_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
is_owner = user.id == folder.user_id
|
||||
is_admin = user.role == 'admin'
|
||||
if not (is_owner or is_admin or await _has_folder_access(user.id, folder, 'read', db)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
folder_ids = (
|
||||
await Folders.get_folder_ids_by_id_and_user_id_in_subtree(id, folder.user_id, db=db)
|
||||
if is_owner or is_admin
|
||||
else [id]
|
||||
)
|
||||
updated_count = await Chats.mark_chats_read_by_folder_ids(user.id, folder_ids, db=db)
|
||||
|
||||
return {
|
||||
'folder_id': id,
|
||||
'folder_ids': folder_ids,
|
||||
'updated_count': updated_count,
|
||||
'folder_unread_counts': await get_folder_unread_counts(user.id, db=db),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
@@ -534,26 +651,18 @@ async def delete_folder_by_id(
|
||||
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
|
||||
if not folder:
|
||||
# Check if it's a shared subfolder with write access
|
||||
# Deletion cascades into the owner's data, so only the owner or an admin may delete
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if folder and folder.parent_id:
|
||||
if user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
elif folder and not folder.parent_id:
|
||||
# Root shared folders can only be deleted by owner/admin
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
else:
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
folder_owner_id = folder.user_id
|
||||
|
||||
@@ -585,6 +694,8 @@ async def delete_folder_by_id(
|
||||
# Clean up access grants for this folder
|
||||
await AccessGrants.revoke_all_access('folder', folder_id, db=db)
|
||||
|
||||
await Automations.clear_folder_ids(folder_owner_id, folder_ids, db=db)
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.FOLDER_DELETED,
|
||||
|
||||
@@ -10,8 +10,8 @@ import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
|
||||
from open_webui.events import EVENTS, build_event, dispatch_event_functions, publish_event, schedule_webhook_dispatch
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.functions import (
|
||||
FunctionForm,
|
||||
@@ -46,11 +46,17 @@ router = APIRouter()
|
||||
|
||||
@router.get('/', response_model=list[FunctionResponse])
|
||||
async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_functions(db=db)
|
||||
|
||||
|
||||
@router.get('/list', response_model=list[FunctionUserResponse])
|
||||
async def get_function_list(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_function_list(db=db)
|
||||
|
||||
|
||||
@@ -65,6 +71,9 @@ async def get_functions(
|
||||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_functions(include_valves=include_valves, db=db)
|
||||
|
||||
|
||||
@@ -284,6 +293,22 @@ async def toggle_function_by_id(
|
||||
):
|
||||
function = await Functions.get_function_by_id(id, db=db)
|
||||
if function:
|
||||
lifecycle_event = build_event(
|
||||
request,
|
||||
EVENTS.FUNCTION_DISABLE_STARTED if function.is_active else EVENTS.FUNCTION_ENABLE_STARTED,
|
||||
actor=user,
|
||||
subject_id=function.id,
|
||||
subject_type='function',
|
||||
data={'type': function.type, 'name': function.name},
|
||||
)
|
||||
await dispatch_event_functions(
|
||||
request.app,
|
||||
lifecycle_event,
|
||||
request=request,
|
||||
extra_function_ids=[function.id] if not function.is_active else None,
|
||||
)
|
||||
schedule_webhook_dispatch(request.app, lifecycle_event)
|
||||
|
||||
function = await Functions.update_function_by_id(id, {'is_active': not function.is_active}, db=db)
|
||||
|
||||
if function:
|
||||
|
||||
@@ -13,6 +13,7 @@ from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -785,7 +786,11 @@ async def image_generations(
|
||||
images.append({'url': url})
|
||||
return images
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
|
||||
if form_data.model:
|
||||
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
|
||||
# persists the global default and switches the shared backend. Only an
|
||||
# admin may do that; a non-admin generates on the currently configured
|
||||
# checkpoint. The model field is not a per-user selection on this backend.
|
||||
if form_data.model and user.role == 'admin':
|
||||
await set_image_model(request, form_data.model)
|
||||
|
||||
data = {
|
||||
@@ -936,10 +941,10 @@ async def image_edits(
|
||||
if isinstance(file_response, FileResponse):
|
||||
file_path = file_response.path
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
image_data = base64.b64encode(file_bytes).decode('utf-8')
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
image_data = base64.b64encode(file_bytes).decode('utf-8')
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
|
||||
return f'data:{mime_type};base64,{image_data}'
|
||||
return data
|
||||
|
||||
@@ -12,7 +12,7 @@ from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, RAG_EMBEDDING_CONTENT_PREFIX
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
@@ -21,6 +21,7 @@ from open_webui.models.config import Config
|
||||
from open_webui.models.files import FileMetadataResponse, FileModel, FileModelResponse, Files
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.knowledge import (
|
||||
KNOWLEDGE_SORTABLE_FIELDS,
|
||||
KnowledgeDirectoryForm,
|
||||
KnowledgeDirectoryModel,
|
||||
KnowledgeFileListResponse,
|
||||
@@ -73,7 +74,7 @@ async def embed_knowledge_base_metadata(
|
||||
"""Generate and store embedding for knowledge base."""
|
||||
try:
|
||||
content = f'{name}\n\n{description}' if description else name
|
||||
embedding = await request.app.state.EMBEDDING_FUNCTION(content)
|
||||
embedding = await request.app.state.EMBEDDING_FUNCTION(content, prefix=RAG_EMBEDDING_CONTENT_PREFIX)
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=KNOWLEDGE_BASES_COLLECTION,
|
||||
items=[
|
||||
@@ -181,6 +182,8 @@ async def search_knowledge_bases(
|
||||
view_option: str | None = None,
|
||||
source: str | None = None,
|
||||
page: int | None = 1,
|
||||
order_by: str | None = None,
|
||||
direction: str | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
@@ -195,6 +198,10 @@ async def search_knowledge_bases(
|
||||
filter['view_option'] = view_option
|
||||
if source in {'local', 'external'}:
|
||||
filter['source'] = source
|
||||
if order_by in KNOWLEDGE_SORTABLE_FIELDS:
|
||||
filter['order_by'] = order_by
|
||||
if direction in {'asc', 'desc'}:
|
||||
filter['direction'] = direction
|
||||
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
@@ -1936,6 +1943,10 @@ async def sync_knowledge_cleanup(
|
||||
if not file:
|
||||
continue
|
||||
|
||||
# Only clean up files that belong to this knowledge base.
|
||||
if not await Knowledges.has_file(id, file_id, db=db):
|
||||
continue
|
||||
|
||||
await Knowledges.remove_file_from_knowledge_by_id(id, file_id, db=db)
|
||||
|
||||
try:
|
||||
@@ -1960,6 +1971,10 @@ async def sync_knowledge_cleanup(
|
||||
|
||||
# ── Remove orphaned directories (children before parents) ──
|
||||
for dir_id in reversed(form_data.dir_ids):
|
||||
# Only delete directories that belong to this knowledge base.
|
||||
directory = await Knowledges.get_directory_by_id(dir_id, db=db)
|
||||
if not directory or directory.knowledge_id != id:
|
||||
continue
|
||||
await Knowledges.delete_directory(dir_id, move_files_to_parent=False, db=db)
|
||||
|
||||
return {'status': True}
|
||||
|
||||
@@ -5,13 +5,13 @@ import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import RAG_EMBEDDING_CONTENT_PREFIX, RAG_EMBEDDING_QUERY_PREFIX
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.memories import Memories, MemoryModel
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.memory import (
|
||||
@@ -148,7 +148,9 @@ async def add_memory(
|
||||
meta={'created_by': 'manual'},
|
||||
)
|
||||
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(
|
||||
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
|
||||
)
|
||||
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
@@ -208,6 +210,7 @@ async def update_memories(
|
||||
if result.get('status') in {'created', 'updated'}:
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(
|
||||
memory_vector_text(memory.content, memory.path),
|
||||
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
|
||||
user=user,
|
||||
)
|
||||
upsert_items.append(
|
||||
@@ -284,7 +287,7 @@ async def query_memory(
|
||||
if not memories:
|
||||
raise HTTPException(status_code=404, detail='No memories found for user')
|
||||
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, RAG_EMBEDDING_QUERY_PREFIX, user=user)
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user)
|
||||
|
||||
results = await ASYNC_VECTOR_DB_CLIENT.search(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
@@ -407,7 +410,9 @@ async def reset_memory_from_vector_db(
|
||||
# Generate vectors in parallel
|
||||
vectors = await asyncio.gather(
|
||||
*[
|
||||
request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
|
||||
request.app.state.EMBEDDING_FUNCTION(
|
||||
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
|
||||
)
|
||||
for memory in memories
|
||||
]
|
||||
)
|
||||
@@ -503,7 +508,9 @@ async def update_memory_by_id(
|
||||
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if form_data.content is not None or form_data.path is not None:
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(memory_vector_text(memory.content, memory.path), user=user)
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(
|
||||
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
|
||||
)
|
||||
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
|
||||
@@ -40,6 +40,7 @@ from open_webui.models.models import (
|
||||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -48,6 +49,14 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def add_chat_variables_schema(model_dict: dict) -> dict:
|
||||
system = (model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None
|
||||
schema = get_chat_variables_schema(system)
|
||||
if schema:
|
||||
model_dict.setdefault('meta', {})['chat_variables_schema'] = schema
|
||||
return model_dict
|
||||
|
||||
|
||||
def _safe_static_redirect_path(url: str) -> str | None:
|
||||
"""
|
||||
If url is a same-origin static asset path, return a normalized path safe for
|
||||
@@ -119,7 +128,11 @@ async def _verify_knowledge_file_access(
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
@router.get('/list', response_model=ModelAccessListResponse) # do NOT use "/" as path, conflicts with main.py
|
||||
@router.get(
|
||||
'/list',
|
||||
response_model=ModelAccessListResponse,
|
||||
response_model_exclude={'items': {'__all__': {'meta': {'profile_image_url'}}}},
|
||||
) # do NOT use "/" as path, conflicts with main.py
|
||||
async def get_models(
|
||||
query: str | None = None,
|
||||
view_option: str | None = None,
|
||||
@@ -173,19 +186,19 @@ async def get_models(
|
||||
# Strip profile_image_url from meta — images are served via /model/profile/image.
|
||||
items = []
|
||||
for model in result.items:
|
||||
data = model.model_dump()
|
||||
data = add_chat_variables_schema(model.model_dump())
|
||||
if data.get('meta'):
|
||||
data['meta'].pop('profile_image_url', None)
|
||||
items.append(
|
||||
ModelAccessResponse(
|
||||
**data,
|
||||
write_access=(
|
||||
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model.user_id
|
||||
or model.id in writable_model_ids
|
||||
),
|
||||
)
|
||||
write_access = (
|
||||
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model.user_id
|
||||
or model.id in writable_model_ids
|
||||
)
|
||||
# Strip params (system prompt and other curated config) for read-only
|
||||
# callers, mirroring the per-id endpoint.
|
||||
if not write_access:
|
||||
data['params'] = {}
|
||||
items.append(ModelAccessResponse(**data, write_access=write_access))
|
||||
|
||||
return ModelAccessListResponse(
|
||||
items=items,
|
||||
@@ -415,7 +428,10 @@ async def import_models(
|
||||
continue
|
||||
|
||||
# Update existing model
|
||||
model_data['meta'] = model_data.get('meta', {})
|
||||
model_data['meta'] = {
|
||||
**existing_model.meta.model_dump(),
|
||||
**(model_data.get('meta') or {}),
|
||||
}
|
||||
model_data['params'] = model_data.get('params', {})
|
||||
|
||||
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
|
||||
@@ -520,6 +536,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
|
||||
db=db,
|
||||
):
|
||||
model_dict = model.model_dump()
|
||||
model_dict = add_chat_variables_schema(model_dict)
|
||||
# Strip params (system prompt and other admin-curated config)
|
||||
# for read-only callers — matches the params strip already
|
||||
# enforced on /api/models in utils/models.py. Owners, admins
|
||||
@@ -721,6 +738,9 @@ async def update_model_by_id(
|
||||
if 'base_model_id' not in form_data.model_fields_set:
|
||||
form_data.base_model_id = model.base_model_id
|
||||
|
||||
if 'profile_image_url' not in form_data.meta.model_fields_set:
|
||||
form_data.meta.profile_image_url = model.meta.profile_image_url
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||
from open_webui.config import (
|
||||
@@ -12,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.chats import ChatForm, ChatResponse, Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.notes import (
|
||||
@@ -303,6 +305,229 @@ async def get_note_by_id(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{id}/chat', response_model=ChatResponse)
|
||||
async def get_note_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
log.info('[note-chat] get-or-create requested note_id=%s user_id=%s', id, user.id)
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chat = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db)
|
||||
if chat:
|
||||
log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
payload = {**(chat.chat or {})}
|
||||
params = {**(payload.get('params') or {})}
|
||||
changed = False
|
||||
|
||||
if params.pop('note_id', None) is not None:
|
||||
changed = True
|
||||
|
||||
system = (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
if params.get('system') != system:
|
||||
params['system'] = system
|
||||
changed = True
|
||||
|
||||
if payload.pop('system', None) is not None:
|
||||
changed = True
|
||||
|
||||
payload['params'] = params
|
||||
if changed:
|
||||
updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False)
|
||||
if updated_chat:
|
||||
return updated_chat
|
||||
|
||||
return chat
|
||||
|
||||
chat_id = str(uuid4())
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': 'Chat',
|
||||
'models': [''],
|
||||
'params': {
|
||||
'system': (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
},
|
||||
'history': {'messages': {}, 'currentId': None},
|
||||
'messages': [],
|
||||
'tags': [],
|
||||
}
|
||||
),
|
||||
db=db,
|
||||
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
|
||||
)
|
||||
if not chat:
|
||||
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
return chat
|
||||
|
||||
|
||||
@router.get('/{id}/chats', response_model=list[ChatResponse])
|
||||
async def get_note_chats_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db)
|
||||
normalized_chats = []
|
||||
for chat in chats:
|
||||
payload = {**(chat.chat or {})}
|
||||
params = {**(payload.get('params') or {})}
|
||||
changed = False
|
||||
|
||||
if params.pop('note_id', None) is not None:
|
||||
changed = True
|
||||
|
||||
system = (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
if params.get('system') != system:
|
||||
params['system'] = system
|
||||
changed = True
|
||||
|
||||
if payload.pop('system', None) is not None:
|
||||
changed = True
|
||||
|
||||
payload['params'] = params
|
||||
if changed:
|
||||
chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) or chat
|
||||
|
||||
normalized_chats.append(chat)
|
||||
|
||||
return normalized_chats
|
||||
|
||||
|
||||
@router.post('/{id}/chat', response_model=ChatResponse)
|
||||
async def create_note_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chat_id = str(uuid4())
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': 'Chat',
|
||||
'models': [''],
|
||||
'params': {
|
||||
'system': (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
},
|
||||
'history': {'messages': {}, 'currentId': None},
|
||||
'messages': [],
|
||||
'tags': [],
|
||||
}
|
||||
),
|
||||
db=db,
|
||||
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
|
||||
)
|
||||
if not chat:
|
||||
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
return chat
|
||||
|
||||
|
||||
############################
|
||||
# UpdateNoteById
|
||||
############################
|
||||
@@ -354,9 +579,15 @@ async def update_note_by_id(
|
||||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
note.is_pinned = note.id in pinned_note_ids
|
||||
|
||||
event_data = note.model_dump()
|
||||
if form_data.data is not None:
|
||||
event_data['data'] = {
|
||||
key: note.data.get(key) for key in form_data.data.keys() if note.data is not None and key in note.data
|
||||
}
|
||||
|
||||
await sio.emit(
|
||||
'note-events',
|
||||
note.model_dump(),
|
||||
'events:note',
|
||||
event_data,
|
||||
to=f'note:{note.id}',
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.notifications import (
|
||||
create_target,
|
||||
delete_target,
|
||||
get_notification_event_catalog,
|
||||
list_targets,
|
||||
set_default_target,
|
||||
test_target,
|
||||
update_target,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NotificationTargetForm(BaseModel):
|
||||
id: str | None = None
|
||||
type: str | None = None
|
||||
enabled: bool | None = None
|
||||
events: list[str] | None = None
|
||||
delivery: str | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
async def _check_notifications_access(user) -> None:
|
||||
if not await Config.get('ui.enable_user_webhooks'):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.webhooks', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
|
||||
|
||||
@router.get('/events')
|
||||
async def get_notification_events(user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
return {'events': get_notification_event_catalog()}
|
||||
|
||||
|
||||
@router.get('/targets')
|
||||
async def get_notification_targets(user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
return await list_targets(user.id)
|
||||
|
||||
|
||||
@router.post('/targets')
|
||||
async def create_notification_target(form_data: NotificationTargetForm, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await create_target(user.id, form_data.model_dump(exclude_unset=True))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.put('/targets/{target_id}')
|
||||
async def update_notification_target(
|
||||
target_id: str, form_data: NotificationTargetForm, user=Depends(get_verified_user)
|
||||
):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await update_target(user.id, target_id, form_data.model_dump(exclude_unset=True))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete('/targets/{target_id}')
|
||||
async def delete_notification_target(target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
if not await delete_target(user.id, target_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
@router.put('/targets/{target_id}/default')
|
||||
async def set_default_notification_target(target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await set_default_target(user.id, target_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/targets/{target_id}/test')
|
||||
async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI')
|
||||
return await test_target(user.id, target_id, app_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
@@ -11,6 +11,7 @@ from datetime import datetime
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
@@ -23,8 +24,8 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
@@ -39,13 +40,15 @@ from open_webui.models.users import UserModel
|
||||
from open_webui.utils.access_control import check_model_access
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.misc import calculate_sha256
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_ollama,
|
||||
apply_model_params_to_body_openai,
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
|
||||
from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,6 +57,7 @@ log = logging.getLogger(__name__)
|
||||
# clients to attempt decompression of an already-decoded payload, resulting
|
||||
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
|
||||
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
|
||||
_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
|
||||
|
||||
def _clean_proxy_headers(raw_headers) -> dict:
|
||||
@@ -81,9 +85,9 @@ async def send_get_request(
|
||||
url,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
timeout=_MODEL_LIST_TIMEOUT,
|
||||
) as r:
|
||||
return await r.json()
|
||||
return await r.json(loads=JSONCodec.loads)
|
||||
except Exception as exc:
|
||||
log.error(f'Connection error: {exc}')
|
||||
return None
|
||||
@@ -97,6 +101,8 @@ async def send_request(
|
||||
key: str | None = None,
|
||||
user: UserModel = None,
|
||||
stream: bool = False,
|
||||
# passthrough must stay False for /api/chat: middleware parses it per line
|
||||
passthrough: bool = False,
|
||||
content_type: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
api_config: dict | None = None,
|
||||
@@ -119,7 +125,7 @@ async def send_request(
|
||||
|
||||
# Custom per-connection headers last so admin-set headers take precedence.
|
||||
if api_config and api_config.get('headers'):
|
||||
headers.update(get_custom_headers(api_config['headers'], user, metadata, request=request))
|
||||
headers.update(await get_custom_headers(api_config['headers'], user, metadata, request=request))
|
||||
|
||||
r = await session.request(
|
||||
method,
|
||||
@@ -127,12 +133,12 @@ async def send_request(
|
||||
data=payload,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=stream),
|
||||
)
|
||||
|
||||
if not r.ok:
|
||||
try:
|
||||
res = await r.json()
|
||||
res = await r.json(loads=JSONCodec.loads)
|
||||
await publish_model_provider_request_failed(
|
||||
request,
|
||||
actor=user,
|
||||
@@ -168,13 +174,13 @@ async def send_request(
|
||||
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
stream_wrapper(r, passthrough=passthrough),
|
||||
status_code=r.status,
|
||||
headers=response_headers,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
return await r.json()
|
||||
return await r.json(loads=JSONCodec.loads)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -261,16 +267,16 @@ async def verify_connection(
|
||||
f'{form_data.url}/api/version',
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
timeout=_MODEL_LIST_TIMEOUT,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
detail = f'HTTP Error: {r.status}'
|
||||
res = await r.json()
|
||||
res = await r.json(loads=JSONCodec.loads)
|
||||
if 'error' in res:
|
||||
detail = f'External Error: {res["error"]}'
|
||||
raise Exception(detail)
|
||||
|
||||
return await r.json()
|
||||
return await r.json(loads=JSONCodec.loads)
|
||||
except aiohttp.ClientError as exc:
|
||||
log.exception(f'Client error: {exc}')
|
||||
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
@@ -313,6 +319,16 @@ async def update_config(
|
||||
'ollama.api_configs': api_configs,
|
||||
}
|
||||
)
|
||||
|
||||
await get_all_models.cache.clear()
|
||||
request.app.state.BASE_MODELS = []
|
||||
request.app.state.OLLAMA_MODELS = {}
|
||||
models = getattr(request.app.state, 'MODELS', None)
|
||||
if hasattr(models, 'clear'):
|
||||
models.clear()
|
||||
else:
|
||||
request.app.state.MODELS = {}
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
|
||||
@@ -355,6 +371,12 @@ def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict:
|
||||
return api_configs.get(str(idx), api_configs.get(url, {}))
|
||||
|
||||
|
||||
async def get_ollama_connection_config() -> tuple[list, dict]:
|
||||
"""Base URLs and per-connection API configs in one batched SELECT."""
|
||||
config = await Config.get_many('ollama.base_urls', 'ollama.api_configs')
|
||||
return config.get('ollama.base_urls', []), config.get('ollama.api_configs', {})
|
||||
|
||||
|
||||
@cached(
|
||||
ttl=MODELS_CACHE_TTL,
|
||||
# key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a
|
||||
@@ -517,6 +539,7 @@ async def get_ollama_loaded_models(
|
||||
@router.get('/api/version/{url_idx}')
|
||||
async def get_ollama_versions(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
url_idx: int | None = None,
|
||||
):
|
||||
"""Return the lowest Ollama version across all configured backends."""
|
||||
@@ -638,6 +661,7 @@ async def pull_model(
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
passthrough=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -677,6 +701,7 @@ async def push_model(
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
passthrough=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -709,6 +734,7 @@ async def create_model(
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
stream=True,
|
||||
passthrough=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -871,16 +897,13 @@ async def embed(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
key = get_api_key(url_idx, url, api_configs)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
|
||||
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/embed',
|
||||
@@ -925,16 +948,13 @@ async def embeddings(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
key = get_api_key(url_idx, url, api_configs)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
|
||||
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/embeddings',
|
||||
@@ -984,22 +1004,20 @@ async def generate_completion(
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
form_data.model = form_data.model.replace(f'{prefix_id}.', '')
|
||||
form_data.model = strip_provider_model_prefix(form_data.model, prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/generate',
|
||||
payload=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=True,
|
||||
passthrough=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -1051,6 +1069,10 @@ async def get_ollama_url(request: Request, model: str, url_idx: int | None = Non
|
||||
await validate_ollama_backend_idx(request, model, url_idx, user)
|
||||
if url_idx is None:
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
if not models or model not in models:
|
||||
await get_all_models.cache.clear()
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
if model not in models:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -1095,7 +1117,7 @@ async def generate_chat_completion(
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
if isinstance(form_data, BaseModel):
|
||||
payload = {**form_data.model_dump(exclude_none=True)}
|
||||
payload = form_data.model_dump(exclude_none=True)
|
||||
|
||||
payload.pop('metadata', None)
|
||||
|
||||
@@ -1119,16 +1141,16 @@ async def generate_chat_completion(
|
||||
await check_model_access(user, None, bypass_filter)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/api/chat',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=form_data.stream,
|
||||
content_type='application/x-ndjson',
|
||||
@@ -1170,6 +1192,14 @@ class OpenAICompletionForm(BaseModel):
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
class OpenAIEmbeddingsForm(BaseModel):
|
||||
"""Payload for the OpenAI-compatible /v1/embeddings proxy."""
|
||||
|
||||
model: str
|
||||
input: object
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
@router.post('/v1/completions')
|
||||
@router.post('/v1/completions/{url_idx}')
|
||||
async def generate_openai_completion(
|
||||
@@ -1191,8 +1221,7 @@ async def generate_openai_completion(
|
||||
log.exception(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])}
|
||||
payload.pop('metadata', None)
|
||||
payload = form_data.model_dump(exclude_none=True, exclude=['metadata'])
|
||||
|
||||
model_id = form_data.model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
@@ -1207,18 +1236,68 @@ async def generate_openai_completion(
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/completions',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
passthrough=True,
|
||||
metadata=metadata,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/v1/embeddings')
|
||||
@router.post('/v1/embeddings/{url_idx}')
|
||||
async def generate_openai_embeddings(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
url_idx: int | None = None,
|
||||
user=Depends(get_verified_user), # noqa: B008
|
||||
):
|
||||
"""Forward an embeddings request via the OpenAI-compatible proxy."""
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
metadata = form_data.pop('metadata', None)
|
||||
|
||||
try:
|
||||
form_data = OpenAIEmbeddingsForm(**form_data)
|
||||
except Exception as exc:
|
||||
log.exception(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
payload = form_data.model_dump(exclude_none=True)
|
||||
payload.pop('metadata', None)
|
||||
|
||||
model_id = form_data.model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
if model_info is not None:
|
||||
if model_info.base_model_id:
|
||||
payload['model'] = model_info.base_model_id
|
||||
await check_model_access(user, model_info)
|
||||
else:
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/embeddings',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
metadata=metadata,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
@@ -1246,8 +1325,7 @@ async def generate_openai_chat_completion(
|
||||
log.exception(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])}
|
||||
payload.pop('metadata', None)
|
||||
payload = form_data.model_dump(exclude_none=True, exclude=['metadata'])
|
||||
|
||||
model_id = form_data.model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
@@ -1266,18 +1344,19 @@ async def generate_openai_chat_completion(
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/chat/completions',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
passthrough=True,
|
||||
metadata=metadata,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
@@ -1317,21 +1396,19 @@ async def generate_anthropic_messages(
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
|
||||
)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/messages',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
passthrough=True,
|
||||
content_type='text/event-stream' if payload.get('stream', False) else None,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
@@ -1377,21 +1454,19 @@ async def generate_responses(
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
|
||||
)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/responses',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
passthrough=True,
|
||||
content_type='text/event-stream' if payload.get('stream', False) else None,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
@@ -1462,7 +1537,7 @@ async def download_file_stream(
|
||||
file_url: str,
|
||||
file_path: str,
|
||||
file_name: str,
|
||||
chunk_size: int = 1024 * 1024,
|
||||
chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
):
|
||||
"""Stream a model file download from *file_url*, then push the blob to Ollama."""
|
||||
current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
@@ -1477,37 +1552,38 @@ async def download_file_stream(
|
||||
) as response:
|
||||
total_size = int(response.headers.get('content-length', 0)) + current_size
|
||||
|
||||
with open(file_path, 'ab+') as f:
|
||||
async with aiofiles.open(file_path, 'ab') as f:
|
||||
async for data in response.content.iter_chunked(chunk_size):
|
||||
current_size += len(data)
|
||||
f.write(data)
|
||||
await f.write(data)
|
||||
|
||||
done = current_size == total_size
|
||||
progress = round((current_size / total_size) * 100, 2)
|
||||
progress_total = total_size or current_size
|
||||
progress = round((current_size / progress_total) * 100, 2)
|
||||
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
|
||||
|
||||
if done:
|
||||
f.close()
|
||||
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
|
||||
done = True
|
||||
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
|
||||
|
||||
def _read_blob():
|
||||
with open(file_path, 'rb') as blob_f:
|
||||
return blob_f.read()
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
|
||||
blob_size = await asyncio.to_thread(os.path.getsize, file_path)
|
||||
|
||||
blob_data = await asyncio.to_thread(_read_blob)
|
||||
async def blob_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as blob_file:
|
||||
while chunk := await blob_file.read(chunk_size):
|
||||
yield chunk
|
||||
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as blob_resp:
|
||||
if blob_resp.ok:
|
||||
os.remove(file_path)
|
||||
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
|
||||
else:
|
||||
raise RuntimeError('Ollama: Could not create blob, Please try again.')
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_chunks(),
|
||||
headers={'Content-Length': str(blob_size)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as blob_resp:
|
||||
if blob_resp.ok:
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
|
||||
else:
|
||||
raise RuntimeError('Ollama: Could not create blob, Please try again.')
|
||||
|
||||
|
||||
@router.post('/models/download')
|
||||
@@ -1554,17 +1630,11 @@ async def upload_model(
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
# Stage 1: persist the uploaded file to disk
|
||||
chunk_size = 1024 * 1024 * 2 # 2 MiB
|
||||
chunk_size = AIOHTTP_FILE_STREAM_CHUNK_SIZE
|
||||
|
||||
def _persist_upload():
|
||||
with open(file_path, 'wb') as out_f:
|
||||
while True:
|
||||
chunk = file.file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
out_f.write(chunk)
|
||||
|
||||
await asyncio.to_thread(_persist_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as out_f:
|
||||
while chunk := await file.read(chunk_size):
|
||||
await out_f.write(chunk)
|
||||
|
||||
async def file_process_stream():
|
||||
nonlocal ollama_url
|
||||
@@ -1576,33 +1646,34 @@ async def upload_model(
|
||||
log.info(f'Model Hash: {file_hash}')
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
bytes_read = 0
|
||||
while chunk := f.read(chunk_size):
|
||||
bytes_read = 0
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
while chunk := await f.read(chunk_size):
|
||||
bytes_read += len(chunk)
|
||||
progress = round(bytes_read / total_size * 100, 2)
|
||||
yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n'
|
||||
|
||||
# Stage 3: push blob to Ollama
|
||||
def _read_blob():
|
||||
with open(file_path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
blob_data = await asyncio.to_thread(_read_blob)
|
||||
event = json.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read})
|
||||
yield f'data: {event}\n\n'
|
||||
|
||||
session = await get_session()
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}'
|
||||
|
||||
async def blob_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as blob_file:
|
||||
while chunk := await blob_file.read(chunk_size):
|
||||
yield chunk
|
||||
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_data,
|
||||
data=blob_chunks(),
|
||||
headers={'Content-Length': str(total_size)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(),
|
||||
) as resp:
|
||||
if not resp.ok:
|
||||
raise Exception('Ollama: Could not create blob, Please try again.')
|
||||
|
||||
log.info('Uploaded to /api/blobs')
|
||||
os.remove(file_path)
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
|
||||
# Stage 4: create the model
|
||||
model, _ext = os.path.splitext(filename)
|
||||
@@ -1619,11 +1690,14 @@ async def upload_model(
|
||||
headers={'Content-Type': 'application/json'},
|
||||
data=json.dumps(create_payload),
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(),
|
||||
) as create_resp:
|
||||
if create_resp.ok:
|
||||
log.info('API SUCCESS!')
|
||||
yield f'data: {json.dumps({"done": True, "blob": f"sha256:{file_hash}", "name": filename, "model_created": model})}\n\n'
|
||||
event = json.dumps(
|
||||
{'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model}
|
||||
)
|
||||
yield f'data: {event}\n\n'
|
||||
else:
|
||||
resp_text = await create_resp.text()
|
||||
raise Exception(f'Failed to create model in Ollama. {resp_text}')
|
||||
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
from typing import Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
||||
@@ -25,7 +26,6 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
@@ -43,6 +43,8 @@ from open_webui.utils.access_control import check_model_access, has_connection_a
|
||||
from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.model_ids import strip_provider_model_prefix
|
||||
from open_webui.utils.misc import (
|
||||
convert_logit_bias_input_to_json,
|
||||
stream_chunks_handler,
|
||||
@@ -53,6 +55,7 @@ from open_webui.utils.payload import (
|
||||
)
|
||||
from open_webui.utils.session_pool import (
|
||||
cleanup_response,
|
||||
get_client_timeout,
|
||||
get_session,
|
||||
stream_wrapper,
|
||||
)
|
||||
@@ -75,6 +78,8 @@ log = logging.getLogger(__name__)
|
||||
# clients to attempt decompression of an already-decoded payload, resulting
|
||||
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
|
||||
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
|
||||
_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
_UNSUPPORTED_OPENAI_MODEL_KEYWORDS = ('babbage', 'dall-e', 'davinci', 'embedding', 'tts', 'whisper')
|
||||
|
||||
|
||||
def _clean_proxy_headers(raw_headers) -> dict:
|
||||
@@ -89,9 +94,8 @@ async def send_get_request(
|
||||
user: UserModel = None,
|
||||
config=None,
|
||||
):
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with aiohttp.ClientSession(timeout=_MODEL_LIST_TIMEOUT, trust_env=True) as session:
|
||||
if request and config:
|
||||
headers, cookies = await get_headers_and_cookies(request, url, key, config, user=user)
|
||||
else:
|
||||
@@ -109,7 +113,7 @@ async def send_get_request(
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
return await response.json()
|
||||
return await response.json(loads=JSONCodec.loads)
|
||||
except Exception as e:
|
||||
# Handle connection error here
|
||||
log.error(f'Connection error: {e}')
|
||||
@@ -209,7 +213,7 @@ async def get_headers_and_cookies(
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
if config.get('headers') and isinstance(config.get('headers'), dict):
|
||||
custom_headers = get_custom_headers(config.get('headers'), user, metadata, request=request)
|
||||
custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request)
|
||||
headers.update(custom_headers)
|
||||
|
||||
return headers, cookies
|
||||
@@ -301,6 +305,90 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
|
||||
return url, key, api_config
|
||||
|
||||
|
||||
async def get_anthropic_token_count_target(request: Request, form_data: dict, user: UserModel):
|
||||
"""Resolve the upstream LiteLLM connection for an Anthropic token-count request."""
|
||||
requested_model = form_data.get('model')
|
||||
if not requested_model:
|
||||
raise HTTPException(status_code=400, detail='model is required')
|
||||
|
||||
payload = {**form_data}
|
||||
model_id = requested_model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
await check_model_access(user, model_info, BYPASS_MODEL_ACCESS_CONTROL)
|
||||
|
||||
if model_info and model_info.base_model_id:
|
||||
model_id = model_info.base_model_id
|
||||
payload['model'] = model_id
|
||||
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
|
||||
model = models.get(model_id)
|
||||
if not model or 'urlIdx' not in model:
|
||||
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.MODEL_NOT_FOUND())
|
||||
|
||||
url, key, api_config = await get_openai_connection(model['urlIdx'])
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
return requested_model, payload, url, key, headers, cookies
|
||||
|
||||
|
||||
async def count_anthropic_tokens(request: Request, form_data: dict, user: UserModel) -> int:
|
||||
"""Forward an Anthropic token-count request through an OpenAI-compatible connection."""
|
||||
requested_model, payload, url, key, headers, cookies = await get_anthropic_token_count_target(
|
||||
request, form_data, user
|
||||
)
|
||||
request_url = f'{url.rstrip("/")}/messages/count_tokens'
|
||||
response = None
|
||||
|
||||
try:
|
||||
session = await get_session()
|
||||
response = await session.request(
|
||||
method='POST',
|
||||
url=request_url,
|
||||
data=json.dumps(payload),
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=get_client_timeout(),
|
||||
)
|
||||
|
||||
try:
|
||||
response_data = await response.json(loads=JSONCodec.loads)
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
if response.status >= 400:
|
||||
await publish_model_provider_request_failed(
|
||||
request,
|
||||
actor=user,
|
||||
provider='openai-compatible',
|
||||
base_url=url,
|
||||
api_key=key,
|
||||
status=response.status,
|
||||
requested_model=requested_model,
|
||||
upstream_error=response_data,
|
||||
)
|
||||
raise HTTPException(status_code=response.status, detail=response_data)
|
||||
|
||||
input_tokens = response_data.get('input_tokens') if isinstance(response_data, dict) else None
|
||||
if isinstance(input_tokens, bool) or not isinstance(input_tokens, int) or input_tokens < 0:
|
||||
raise HTTPException(status_code=502, detail='Invalid token-count response from upstream provider')
|
||||
|
||||
return input_tokens
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception('Failed to count Anthropic tokens for model %s', requested_model)
|
||||
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
finally:
|
||||
await cleanup_response(response)
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
return await get_openai_config()
|
||||
@@ -333,6 +421,16 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe
|
||||
'openai.api_configs': api_configs,
|
||||
}
|
||||
)
|
||||
|
||||
await get_all_models.cache.clear()
|
||||
request.app.state.BASE_MODELS = []
|
||||
request.app.state.OPENAI_MODELS = {}
|
||||
models = getattr(request.app.state, 'MODELS', None)
|
||||
if hasattr(models, 'clear'):
|
||||
models.clear()
|
||||
else:
|
||||
request.app.state.MODELS = {}
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
|
||||
@@ -396,13 +494,12 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
r.raise_for_status()
|
||||
|
||||
# Save the streaming content to a file
|
||||
with open(file_path, 'wb') as f:
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
async for chunk in r.content.iter_chunked(8192):
|
||||
f.write(chunk)
|
||||
await f.write(chunk)
|
||||
|
||||
with open(file_body_path, 'w') as f:
|
||||
json.dump(json.loads(body.decode('utf-8')), f)
|
||||
async with aiofiles.open(file_body_path, 'w') as f:
|
||||
await f.write(json.dumps(json.loads(body.decode('utf-8'))))
|
||||
|
||||
# Return the saved file
|
||||
return FileResponse(file_path)
|
||||
@@ -413,7 +510,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
detail = None
|
||||
if r is not None:
|
||||
try:
|
||||
res = await r.json()
|
||||
res = await r.json(loads=JSONCodec.loads)
|
||||
if 'error' in res:
|
||||
detail = f'External: {res["error"]}'
|
||||
except Exception:
|
||||
@@ -551,6 +648,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
|
||||
enable_openai_api, api_base_urls, _, api_configs = await get_openai_runtime_config()
|
||||
if not enable_openai_api:
|
||||
request.app.state.OPENAI_MODELS = {}
|
||||
return {'data': []}
|
||||
|
||||
responses = await get_all_models_responses(request, user=user)
|
||||
@@ -563,19 +661,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
return None
|
||||
|
||||
def is_supported_openai_models(model_id):
|
||||
if any(
|
||||
name in model_id
|
||||
for name in [
|
||||
'babbage',
|
||||
'dall-e',
|
||||
'davinci',
|
||||
'embedding',
|
||||
'tts',
|
||||
'whisper',
|
||||
]
|
||||
):
|
||||
return False
|
||||
return True
|
||||
return not any(name in model_id for name in _UNSUPPORTED_OPENAI_MODEL_KEYWORDS)
|
||||
|
||||
def get_merged_models(model_lists):
|
||||
log.debug(f'merge_models_lists {model_lists}')
|
||||
@@ -583,17 +669,18 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
|
||||
for idx, model_list in enumerate(model_lists):
|
||||
if model_list is not None and 'error' not in model_list:
|
||||
base_url = api_base_urls[idx]
|
||||
hostname = urlparse(base_url).hostname if base_url else None
|
||||
api_config = api_configs.get(str(idx), api_configs.get(base_url, {}))
|
||||
|
||||
for model in model_list:
|
||||
model_id = model.get('id') or model.get('name')
|
||||
|
||||
base_url = api_base_urls[idx]
|
||||
hostname = urlparse(base_url).hostname if base_url else None
|
||||
if hostname == 'api.openai.com' and not is_supported_openai_models(model_id):
|
||||
# Skip unwanted OpenAI models
|
||||
continue
|
||||
|
||||
if model_id and model_id not in models:
|
||||
api_config = api_configs.get(str(idx), api_configs.get(base_url, {}))
|
||||
provider = model.get('provider', '')
|
||||
merged = {
|
||||
**model,
|
||||
@@ -642,7 +729,7 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
|
||||
r = None
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
timeout=_MODEL_LIST_TIMEOUT,
|
||||
) as session:
|
||||
try:
|
||||
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
@@ -666,30 +753,20 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
|
||||
if r.status != 200:
|
||||
error_detail = f'HTTP Error: {r.status}'
|
||||
try:
|
||||
res = await r.json()
|
||||
res = await r.json(loads=JSONCodec.loads)
|
||||
if 'error' in res:
|
||||
error_detail = f'External Error: {res["error"]}'
|
||||
except Exception:
|
||||
pass
|
||||
raise Exception(error_detail)
|
||||
|
||||
response_data = await r.json()
|
||||
response_data = await r.json(loads=JSONCodec.loads)
|
||||
|
||||
if 'api.openai.com' in url:
|
||||
response_data['data'] = [
|
||||
model
|
||||
for model in response_data.get('data', [])
|
||||
if not any(
|
||||
name in model['id']
|
||||
for name in [
|
||||
'babbage',
|
||||
'dall-e',
|
||||
'davinci',
|
||||
'embedding',
|
||||
'tts',
|
||||
'whisper',
|
||||
]
|
||||
)
|
||||
if not any(name in model['id'] for name in _UNSUPPORTED_OPENAI_MODEL_KEYWORDS)
|
||||
]
|
||||
|
||||
models = response_data
|
||||
@@ -728,7 +805,7 @@ async def verify_connection(
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
timeout=_MODEL_LIST_TIMEOUT,
|
||||
) as session:
|
||||
try:
|
||||
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
@@ -756,7 +833,7 @@ async def verify_connection(
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
response_data = await r.json(loads=JSONCodec.loads)
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
@@ -782,7 +859,7 @@ async def verify_connection(
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
response_data = await r.json(loads=JSONCodec.loads)
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
@@ -1107,6 +1184,9 @@ async def generate_chat_completion(
|
||||
form_data: dict,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not await Config.get('openai.enable'):
|
||||
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
|
||||
|
||||
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
|
||||
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
|
||||
@@ -1169,8 +1249,7 @@ async def generate_chat_completion(
|
||||
url, key, api_config = await get_openai_connection(idx)
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id)
|
||||
|
||||
# Add user info to the payload if the model is a pipeline
|
||||
if 'pipeline' in model and model.get('pipeline'):
|
||||
@@ -1246,6 +1325,10 @@ async def generate_chat_completion(
|
||||
part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text')
|
||||
)
|
||||
|
||||
is_streaming_request = bool(payload.get('stream', False))
|
||||
if not is_streaming_request:
|
||||
payload.pop('stream_options', None)
|
||||
|
||||
payload = json.dumps(payload)
|
||||
|
||||
r = None
|
||||
@@ -1262,7 +1345,7 @@ async def generate_chat_completion(
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=is_streaming_request),
|
||||
)
|
||||
|
||||
# Check if response is SSE
|
||||
@@ -1314,7 +1397,7 @@ async def generate_chat_completion(
|
||||
)
|
||||
else:
|
||||
try:
|
||||
response = await r.json()
|
||||
response = await r.json(loads=JSONCodec.loads)
|
||||
except Exception as e:
|
||||
log.error(e)
|
||||
response = await r.text()
|
||||
@@ -1413,20 +1496,20 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||
data=body,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(),
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
if 'text/event-stream' in r.headers.get('Content-Type', ''):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
stream_wrapper(r, passthrough=True),
|
||||
status_code=r.status,
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
response_data = await r.json(loads=JSONCodec.loads)
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
@@ -1489,6 +1572,7 @@ async def responses(
|
||||
Routes to the correct upstream backend based on the model field.
|
||||
"""
|
||||
payload = form_data.model_dump(exclude_none=True)
|
||||
is_streaming_request = bool(payload.get('stream', False))
|
||||
|
||||
idx = 0
|
||||
model_id = form_data.model
|
||||
@@ -1539,20 +1623,20 @@ async def responses(
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=is_streaming_request),
|
||||
)
|
||||
|
||||
# Check if response is SSE
|
||||
if 'text/event-stream' in r.headers.get('Content-Type', ''):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
stream_wrapper(r, passthrough=True),
|
||||
status_code=r.status,
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
response_data = await r.json(loads=JSONCodec.loads)
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
@@ -1609,6 +1693,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
payload = None
|
||||
is_streaming_request = bool(payload.get('stream', False)) if isinstance(payload, dict) else False
|
||||
|
||||
idx = 0
|
||||
model_id = payload.get('model') if isinstance(payload, dict) else None
|
||||
@@ -1660,20 +1745,20 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
timeout=get_client_timeout(stream=is_streaming_request),
|
||||
)
|
||||
|
||||
# Check if response is SSE
|
||||
if 'text/event-stream' in r.headers.get('Content-Type', ''):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
stream_wrapper(r, passthrough=True),
|
||||
status_code=r.status,
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
response_data = await r.json(loads=JSONCodec.loads)
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
@@ -18,7 +18,7 @@ from fastapi import (
|
||||
)
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_FILE_STREAM_CHUNK_SIZE
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.routers.openai import get_all_models_responses
|
||||
@@ -237,35 +237,37 @@ async def upload_pipeline(
|
||||
|
||||
response = None
|
||||
try:
|
||||
# Save the uploaded file off the event loop (uploads can be large).
|
||||
def _save_upload():
|
||||
with open(file_path, 'wb') as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
await asyncio.to_thread(_save_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as buffer:
|
||||
while chunk := await file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
await buffer.write(chunk)
|
||||
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
headers = {'Authorization': f'Bearer {key}'}
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
with open(file_path, 'rb') as f:
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field(
|
||||
'file',
|
||||
f,
|
||||
filename=filename,
|
||||
content_type='application/octet-stream',
|
||||
)
|
||||
form_data = aiohttp.FormData()
|
||||
|
||||
async with session.post(
|
||||
f'{url}/pipelines/upload',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
async def pipeline_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as pipeline_file:
|
||||
while chunk := await pipeline_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field(
|
||||
'file',
|
||||
pipeline_chunks(),
|
||||
filename=filename,
|
||||
content_type='application/octet-stream',
|
||||
)
|
||||
|
||||
async with session.post(
|
||||
f'{url}/pipelines/upload',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
@@ -297,7 +299,7 @@ async def upload_pipeline(
|
||||
finally:
|
||||
# Ensure the file is deleted after the upload is completed or on failure
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
|
||||
|
||||
class AddPipelineForm(BaseModel):
|
||||
|
||||
@@ -101,6 +101,7 @@ from open_webui.retrieval.web.ollama import search_ollama_cloud
|
||||
from open_webui.retrieval.web.perplexity import search_perplexity
|
||||
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
|
||||
from open_webui.retrieval.web.searchapi import search_searchapi
|
||||
from open_webui.retrieval.web.openserp import search_openserp
|
||||
from open_webui.retrieval.web.searxng import search_searxng
|
||||
from open_webui.retrieval.web.serpapi import search_serpapi
|
||||
from open_webui.retrieval.web.serper import search_serper
|
||||
@@ -127,6 +128,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TIKTOKEN_DISALLOWED_SPECIAL = ()
|
||||
|
||||
##########################################
|
||||
#
|
||||
# Utility functions
|
||||
@@ -262,6 +265,7 @@ RETRIEVAL_CONFIG_KEYS = {
|
||||
'CHUNK_MIN_SIZE_TARGET': 'rag.chunk_min_size_target',
|
||||
'CHUNK_OVERLAP': 'rag.chunk_overlap',
|
||||
'CHUNK_SIZE': 'rag.chunk_size',
|
||||
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': 'rag.content_extraction.supported_media_mime_types',
|
||||
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
|
||||
'DATALAB_MARKER_ADDITIONAL_CONFIG': 'rag.datalab_marker_additional_config',
|
||||
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
|
||||
@@ -363,6 +367,7 @@ RETRIEVAL_CONFIG_KEYS = {
|
||||
'SEARCHAPI_ENGINE': 'web.search.searchapi_engine',
|
||||
'SEARXNG_LANGUAGE': 'web.search.searxng_language',
|
||||
'SEARXNG_QUERY_URL': 'web.search.searxng_query_url',
|
||||
'OPENSERP_BASE_URL': 'web.search.openserp_base_url',
|
||||
'SERPAPI_API_KEY': 'web.search.serpapi_api_key',
|
||||
'SERPAPI_ENGINE': 'web.search.serpapi_engine',
|
||||
'SERPER_API_KEY': 'web.search.serper_api_key',
|
||||
@@ -626,6 +631,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
'HYBRID_BM25_WEIGHT': config.HYBRID_BM25_WEIGHT,
|
||||
# Content extraction settings
|
||||
'CONTENT_EXTRACTION_ENGINE': config.CONTENT_EXTRACTION_ENGINE,
|
||||
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
|
||||
'PDF_EXTRACT_IMAGES': config.PDF_EXTRACT_IMAGES,
|
||||
'PDF_LOADER_MODE': config.PDF_LOADER_MODE,
|
||||
'DATALAB_MARKER_API_KEY': config.DATALAB_MARKER_API_KEY,
|
||||
@@ -701,6 +707,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL,
|
||||
'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE,
|
||||
'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL,
|
||||
'YACY_QUERY_URL': config.YACY_QUERY_URL,
|
||||
'YACY_USERNAME': config.YACY_USERNAME,
|
||||
'YACY_PASSWORD': config.YACY_PASSWORD,
|
||||
@@ -779,6 +786,7 @@ class WebConfig(BaseModel):
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: str | None = None
|
||||
SEARXNG_QUERY_URL: str | None = None
|
||||
SEARXNG_LANGUAGE: str | None = None
|
||||
OPENSERP_BASE_URL: str | None = None
|
||||
YACY_QUERY_URL: str | None = None
|
||||
YACY_USERNAME: str | None = None
|
||||
YACY_PASSWORD: str | None = None
|
||||
@@ -855,6 +863,7 @@ class ConfigForm(BaseModel):
|
||||
|
||||
# Content extraction settings
|
||||
CONTENT_EXTRACTION_ENGINE: str | None = None
|
||||
CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES: list[str] | None = None
|
||||
PDF_EXTRACT_IMAGES: bool | None = None
|
||||
PDF_LOADER_MODE: str | None = None
|
||||
|
||||
@@ -967,6 +976,11 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
||||
if form_data.CONTENT_EXTRACTION_ENGINE is not None
|
||||
else config.CONTENT_EXTRACTION_ENGINE
|
||||
)
|
||||
config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES = (
|
||||
form_data.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES
|
||||
if form_data.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES is not None
|
||||
else config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES
|
||||
)
|
||||
config.PDF_EXTRACT_IMAGES = (
|
||||
form_data.PDF_EXTRACT_IMAGES if form_data.PDF_EXTRACT_IMAGES is not None else config.PDF_EXTRACT_IMAGES
|
||||
)
|
||||
@@ -1247,6 +1261,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
||||
config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
|
||||
config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
|
||||
config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE
|
||||
config.OPENSERP_BASE_URL = form_data.web.OPENSERP_BASE_URL
|
||||
config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
|
||||
config.YACY_USERNAME = form_data.web.YACY_USERNAME
|
||||
config.YACY_PASSWORD = form_data.web.YACY_PASSWORD
|
||||
@@ -1326,6 +1341,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
||||
'HYBRID_BM25_WEIGHT': config.HYBRID_BM25_WEIGHT,
|
||||
# Content extraction settings
|
||||
'CONTENT_EXTRACTION_ENGINE': config.CONTENT_EXTRACTION_ENGINE,
|
||||
'CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES': config.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES,
|
||||
'PDF_EXTRACT_IMAGES': config.PDF_EXTRACT_IMAGES,
|
||||
'PDF_LOADER_MODE': config.PDF_LOADER_MODE,
|
||||
'DATALAB_MARKER_API_KEY': config.DATALAB_MARKER_API_KEY,
|
||||
@@ -1398,6 +1414,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
||||
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL,
|
||||
'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE,
|
||||
'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL,
|
||||
'YACY_QUERY_URL': config.YACY_QUERY_URL,
|
||||
'YACY_USERNAME': config.YACY_USERNAME,
|
||||
'YACY_PASSWORD': config.YACY_PASSWORD,
|
||||
@@ -1562,12 +1579,21 @@ def get_transformers_tokenizer(request: Request, config: RetrievalConfig):
|
||||
if not os.path.exists(tokenizer_model) and '/' not in tokenizer_model:
|
||||
tokenizer_model = f'sentence-transformers/{tokenizer_model}'
|
||||
|
||||
return AutoTokenizer.from_pretrained(
|
||||
cache_dir = os.getenv('SENTENCE_TRANSFORMERS_HOME') or os.getenv('HF_HUB_CACHE')
|
||||
local_files_only = not RAG_EMBEDDING_MODEL_AUTO_UPDATE
|
||||
tokenizer_key = (tokenizer_model, cache_dir, local_files_only)
|
||||
cached_tokenizer = getattr(request.app.state, 'transformers_tokenizer', None)
|
||||
if cached_tokenizer and cached_tokenizer[0] == tokenizer_key:
|
||||
return cached_tokenizer[1]
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
tokenizer_model,
|
||||
cache_dir=os.getenv('SENTENCE_TRANSFORMERS_HOME') or os.getenv('HF_HUB_CACHE'),
|
||||
cache_dir=cache_dir,
|
||||
trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
|
||||
local_files_only=not RAG_EMBEDDING_MODEL_AUTO_UPDATE,
|
||||
local_files_only=local_files_only,
|
||||
)
|
||||
request.app.state.transformers_tokenizer = (tokenizer_key, tokenizer)
|
||||
return tokenizer
|
||||
|
||||
tokenizer = getattr(getattr(request.app.state, 'ef', None), 'tokenizer', None)
|
||||
if tokenizer is not None:
|
||||
@@ -1582,7 +1608,7 @@ def get_splitter_length_function(
|
||||
) -> Callable[[str], int]:
|
||||
if config.TEXT_SPLITTER == 'token':
|
||||
encoding = tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME))
|
||||
return lambda text: len(encoding.encode(text, disallowed_special=()))
|
||||
return lambda text: len(encoding.encode(text, disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL))
|
||||
|
||||
if config.TEXT_SPLITTER == 'token_transformers':
|
||||
tokenizer = get_transformers_tokenizer(request, config)
|
||||
@@ -1689,6 +1715,7 @@ def save_docs_to_vector_db(
|
||||
chunk_size=config.CHUNK_SIZE,
|
||||
chunk_overlap=config.CHUNK_OVERLAP,
|
||||
add_start_index=True,
|
||||
disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL,
|
||||
)
|
||||
docs = text_splitter.split_documents(docs)
|
||||
elif config.TEXT_SPLITTER == 'token_transformers':
|
||||
@@ -1934,7 +1961,7 @@ async def process_file(
|
||||
]
|
||||
text_content = ' '.join([doc.page_content for doc in docs])
|
||||
|
||||
log.debug(f'text_content: {text_content}')
|
||||
log.debug('text_content: %s', text_content)
|
||||
await Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{'content': text_content},
|
||||
@@ -2076,7 +2103,7 @@ async def process_text(
|
||||
)
|
||||
]
|
||||
text_content = form_data.content
|
||||
log.debug(f'text_content: {text_content}')
|
||||
log.debug('text_content: %s', text_content)
|
||||
|
||||
config = await get_retrieval_config()
|
||||
result = await run_in_threadpool(save_docs_to_vector_db, request, docs, collection_name, config, user=user)
|
||||
@@ -2113,7 +2140,7 @@ async def process_web(
|
||||
config = await get_retrieval_config()
|
||||
try:
|
||||
content, docs = await get_content_from_url(request, form_data.url)
|
||||
log.debug(f'text_content: {content}')
|
||||
log.debug('text_content: %s', content)
|
||||
|
||||
if process:
|
||||
collection_name = form_data.collection_name
|
||||
@@ -2209,6 +2236,16 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li
|
||||
)
|
||||
else:
|
||||
raise Exception('No SEARXNG_QUERY_URL found in environment variables')
|
||||
elif engine == 'openserp':
|
||||
if config.OPENSERP_BASE_URL:
|
||||
return await search_openserp(
|
||||
config.OPENSERP_BASE_URL,
|
||||
query,
|
||||
config.WEB_SEARCH_RESULT_COUNT,
|
||||
config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
else:
|
||||
raise Exception('No OPENSERP_BASE_URL found in environment variables')
|
||||
elif engine == 'yacy':
|
||||
if config.YACY_QUERY_URL:
|
||||
return await asyncio.to_thread(
|
||||
@@ -2604,19 +2641,22 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
|
||||
if hasattr(result, 'snippet') and result.snippet is not None
|
||||
]
|
||||
else:
|
||||
loader_config = await get_loader_config()
|
||||
loader = get_web_loader(
|
||||
urls,
|
||||
verify_ssl=config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
trust_env=config.WEB_SEARCH_TRUST_ENV,
|
||||
verify_ssl=loader_config.get('web_loader_ssl_verification'),
|
||||
requests_per_second=loader_config.get('web_loader_concurrent_requests'),
|
||||
trust_env=loader_config.get('web_search_trust_env'),
|
||||
loader_config=loader_config,
|
||||
)
|
||||
docs = await loader.aload()
|
||||
|
||||
urls = [
|
||||
doc.metadata.get('source') for doc in docs if doc.metadata.get('source')
|
||||
] # only keep the urls returned by the loader
|
||||
url_set = set(urls)
|
||||
result_items = [
|
||||
dict(item) for item in result_items if item.link in urls
|
||||
dict(item) for item in result_items if item.link in url_set
|
||||
] # only keep the search results that have been loaded
|
||||
|
||||
if config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
|
||||
@@ -2636,7 +2676,8 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
|
||||
}
|
||||
else:
|
||||
# Create a single collection for all documents
|
||||
collection_name = f'web-search-{calculate_sha256_string("-".join(form_data.queries))}'[:63]
|
||||
# Bind the ephemeral collection to its owner so filter_accessible_collections can scope it per-user.
|
||||
collection_name = f'web-search-{user.id}-{calculate_sha256_string("-".join(form_data.queries))}'[:63]
|
||||
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
@@ -2649,7 +2690,12 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
|
||||
user=user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'error saving docs: {e}')
|
||||
# Surface the failure instead of returning an unusable collection
|
||||
log.exception(f'Error saving web search results to vector DB: {e}')
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to embed and store the retrieved web pages. Check the embedding configuration in Admin Settings > Documents.',
|
||||
)
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
@@ -2910,21 +2956,24 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo
|
||||
folder = f'{UPLOAD_DIR}'
|
||||
try:
|
||||
# Check if the directory exists
|
||||
if os.path.exists(folder):
|
||||
if await asyncio.to_thread(os.path.exists, folder):
|
||||
# Iterate over all the files and directories in the specified directory
|
||||
for filename in os.listdir(folder):
|
||||
for filename in await asyncio.to_thread(os.listdir, folder):
|
||||
file_path = os.path.join(folder, filename)
|
||||
try:
|
||||
if os.path.isfile(file_path) or os.path.islink(file_path):
|
||||
os.unlink(file_path) # Remove the file or link
|
||||
elif os.path.isdir(file_path):
|
||||
shutil.rmtree(file_path) # Remove the directory
|
||||
if await asyncio.to_thread(os.path.isfile, file_path) or await asyncio.to_thread(
|
||||
os.path.islink, file_path
|
||||
):
|
||||
await asyncio.to_thread(os.unlink, file_path) # Remove the file or link
|
||||
elif await asyncio.to_thread(os.path.isdir, file_path):
|
||||
await asyncio.to_thread(shutil.rmtree, file_path) # Remove the directory
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to delete {file_path}. Reason: {e}')
|
||||
else:
|
||||
log.warning(f'The directory {folder} does not exist')
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to process the directory {folder}. Reason: {e}')
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.RETRIEVAL_UPLOADS_RESET,
|
||||
|
||||
@@ -72,6 +72,8 @@ async def get_skills(
|
||||
async def get_skill_list(
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
@@ -86,6 +88,10 @@ async def get_skill_list(
|
||||
filter['query'] = query
|
||||
if view_option:
|
||||
filter['view_option'] = view_option
|
||||
if order_by:
|
||||
filter['order_by'] = order_by
|
||||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
|
||||
@@ -75,19 +75,6 @@ def config_updates(data: dict, key_map: dict[str, str]) -> dict:
|
||||
##################################
|
||||
|
||||
|
||||
class ActiveChatsForm(BaseModel):
|
||||
chat_ids: list[str]
|
||||
|
||||
|
||||
@router.post('/active/chats')
|
||||
async def check_active_chats(request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)):
|
||||
"""Check which chat IDs have active tasks."""
|
||||
from open_webui.tasks import get_active_chat_ids
|
||||
|
||||
active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids)
|
||||
return {'active_chat_ids': active}
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def get_task_config(request: Request, user=Depends(get_verified_user)):
|
||||
return await get_config_values(TASK_CONFIG_KEYS)
|
||||
|
||||
@@ -17,9 +17,9 @@ from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.access_control import has_connection_access
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
@@ -49,6 +49,10 @@ def _sanitize_proxy_path(path: str) -> str | None:
|
||||
# Fail closed: still encoded after the cap means the upstream would decode further into traversal.
|
||||
if unquote(decoded) != decoded:
|
||||
return None
|
||||
# posixpath splits on '/' only, so 'a/..\..\b' survives normpath as one component.
|
||||
# Upstreams that treat '\' as a separator would resolve it, so reject outright.
|
||||
if '\\' in decoded:
|
||||
return None
|
||||
had_trailing_slash = decoded.endswith('/')
|
||||
normalized = posixpath.normpath(decoded)
|
||||
# Remove any leading slashes that would reset the base
|
||||
@@ -96,11 +100,14 @@ async def proxy_terminal(
|
||||
if connection is None:
|
||||
return JSONResponse({'error': f"Terminal server '{server_id}' not found"}, status_code=404)
|
||||
|
||||
if not connection.get('enabled', True):
|
||||
return JSONResponse({'error': 'Terminal server disabled'}, status_code=403)
|
||||
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
if not await has_connection_access(user, connection, user_group_ids):
|
||||
return JSONResponse({'error': 'Access denied'}, status_code=403)
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
return JSONResponse({'error': 'Terminal server URL not configured'}, status_code=503)
|
||||
|
||||
@@ -110,11 +117,6 @@ async def proxy_terminal(
|
||||
|
||||
target_url = f'{base_url}/{safe_path}'
|
||||
|
||||
# Route through orchestrator policy endpoint if policy_id is set
|
||||
policy_id = connection.get('policy_id')
|
||||
if policy_id:
|
||||
target_url = f'{base_url}/p/{policy_id}/{safe_path}'
|
||||
|
||||
if request.query_params:
|
||||
target_url += f'?{request.query_params}'
|
||||
|
||||
@@ -133,9 +135,18 @@ async def proxy_terminal(
|
||||
headers.update(bearer_auth_header(request.state.token.credentials))
|
||||
elif auth_type == 'system_oauth':
|
||||
cookies = request.cookies
|
||||
oauth_token = request.headers.get('x-oauth-access-token', '')
|
||||
# Resolve the token server-side from the caller's OAuth session; never trust a client header.
|
||||
oauth_token = None
|
||||
try:
|
||||
if request.cookies.get('oauth_session_id', None):
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get('oauth_session_id', None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f'Error getting OAuth token: {e}')
|
||||
if oauth_token:
|
||||
headers.update(bearer_auth_header(oauth_token))
|
||||
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
|
||||
# auth_type == "none": no Authorization header
|
||||
|
||||
content_type = request.headers.get('content-type')
|
||||
@@ -212,7 +223,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from open_webui.utils.auth import decode_token, is_valid_token
|
||||
from open_webui.utils.auth import get_verified_user_by_token
|
||||
|
||||
# First-message authentication
|
||||
try:
|
||||
@@ -221,14 +232,9 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||
if payload.get('type') != 'auth':
|
||||
await ws.close(code=4001, reason='Expected auth message')
|
||||
return None
|
||||
token = payload.get('token', '')
|
||||
data = decode_token(token)
|
||||
if data is None or 'id' not in data or not await is_valid_token(data, getattr(ws.app.state, 'redis', None)):
|
||||
await ws.close(code=4001, reason='Invalid token')
|
||||
return None
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
user = await get_verified_user_by_token(payload.get('token', ''), getattr(ws.app.state, 'redis', None))
|
||||
if user is None:
|
||||
await ws.close(code=4001, reason='User not found')
|
||||
await ws.close(code=4001, reason='Invalid token')
|
||||
return None
|
||||
except (asyncio.TimeoutError, json.JSONDecodeError):
|
||||
await ws.close(code=4001, reason='Auth timeout or invalid payload')
|
||||
@@ -245,6 +251,10 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||
await ws.close(code=4004, reason='Terminal server not found')
|
||||
return None
|
||||
|
||||
if not connection.get('enabled', True):
|
||||
await ws.close(code=4003, reason='Terminal server disabled')
|
||||
return None
|
||||
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
if not await has_connection_access(user, connection, user_group_ids):
|
||||
await ws.close(code=4003, reason='Access denied')
|
||||
@@ -272,7 +282,7 @@ async def ws_terminal(
|
||||
return
|
||||
user, connection = result
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
await ws.close(code=4003, reason='Terminal server URL not configured')
|
||||
return
|
||||
@@ -280,8 +290,6 @@ async def ws_terminal(
|
||||
# Build upstream WebSocket URL (no token in URL)
|
||||
ws_base = base_url.replace('https://', 'wss://').replace('http://', 'ws://')
|
||||
|
||||
# Route through orchestrator policy endpoint if policy_id is set
|
||||
policy_id = connection.get('policy_id')
|
||||
upstream_params = {}
|
||||
# For orchestrator-backed servers, pass user_id
|
||||
upstream_params['user_id'] = user.id
|
||||
@@ -292,10 +300,7 @@ async def ws_terminal(
|
||||
# decode depth) and inject an attacker-chosen user_id ahead of the one appended below.
|
||||
safe_session_id = urllib.parse.quote(session_id, safe='')
|
||||
|
||||
if policy_id:
|
||||
upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{safe_session_id}'
|
||||
else:
|
||||
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
|
||||
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
|
||||
if upstream_params:
|
||||
upstream_url += f'?{urllib.parse.urlencode(upstream_params)}'
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
@@ -72,20 +72,23 @@ async def get_tools(
|
||||
tools = []
|
||||
|
||||
# Local Tools
|
||||
tools_cache = get_tools_cache(request)
|
||||
for tool in await Tools.get_tools(defer_content=True, db=db):
|
||||
tool_module = tools_cache.get(tool.id)
|
||||
has_user_valves = (
|
||||
hasattr(tool_module, 'UserValves') if tool_module else (tool.meta.has_user_valves if tool.meta else False)
|
||||
)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
'has_user_valves': has_user_valves,
|
||||
}
|
||||
if ENABLE_PLUGINS:
|
||||
tools_cache = get_tools_cache(request)
|
||||
for tool in await Tools.get_tools(defer_content=True, db=db):
|
||||
tool_module = tools_cache.get(tool.id)
|
||||
has_user_valves = (
|
||||
hasattr(tool_module, 'UserValves')
|
||||
if tool_module
|
||||
else (tool.meta.has_user_valves if tool.meta else False)
|
||||
)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
'has_user_valves': has_user_valves,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# OpenAPI Tool Servers
|
||||
server_access_grants = {}
|
||||
@@ -199,6 +202,9 @@ async def get_tools(
|
||||
|
||||
@router.get('/list', response_model=list[ToolAccessResponse])
|
||||
async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
tools = await Tools.get_tools(defer_content=True, db=db)
|
||||
else:
|
||||
@@ -440,20 +446,22 @@ async def get_tools_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return ToolAccessResponse(
|
||||
**tools.model_dump(),
|
||||
write_access=(
|
||||
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == tools.user_id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='tool',
|
||||
resource_id=tools.id,
|
||||
permission='write',
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
write_access = (
|
||||
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == tools.user_id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='tool',
|
||||
resource_id=tools.id,
|
||||
permission='write',
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
data = tools.model_dump()
|
||||
if not write_access:
|
||||
# extra='allow' re-admits content from model_dump; source is writer-only
|
||||
data.pop('content', None)
|
||||
return ToolAccessResponse(**data, write_access=write_access)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -4,9 +4,11 @@ import base64
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
@@ -14,6 +16,8 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_AL
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.auths import Auths
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.users import (
|
||||
@@ -40,6 +44,7 @@ from open_webui.utils.auth import (
|
||||
get_verified_user,
|
||||
validate_password,
|
||||
)
|
||||
from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -196,12 +201,14 @@ class SharingPermissions(BaseModel):
|
||||
public_skills: bool = False
|
||||
notes: bool = False
|
||||
public_notes: bool = True
|
||||
folders: bool = False
|
||||
public_chats: bool = False
|
||||
public_calendars: bool = False
|
||||
|
||||
|
||||
class AccessGrantsPermissions(BaseModel):
|
||||
allow_users: bool = True
|
||||
allow_groups: bool = True
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
@@ -259,6 +266,129 @@ class UserPermissions(BaseModel):
|
||||
settings: SettingsPermissions
|
||||
|
||||
|
||||
class UserUsageTotals(BaseModel):
|
||||
lifetime_tokens: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
peak_daily_tokens: int = 0
|
||||
longest_chat_seconds: int = 0
|
||||
current_streak: int = 0
|
||||
longest_streak: int = 0
|
||||
total_chats: int = 0
|
||||
active_days: int = 0
|
||||
models_used: int = 0
|
||||
messages: int = 0
|
||||
user_messages: int = 0
|
||||
assistant_messages: int = 0
|
||||
|
||||
|
||||
class UserUsageHeatmapEntry(BaseModel):
|
||||
date: str
|
||||
messages: int = 0
|
||||
chats: int = 0
|
||||
tokens: int = 0
|
||||
models: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserUsageModelEntry(BaseModel):
|
||||
model_id: str
|
||||
messages: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
class UserUsageToolEntry(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
|
||||
|
||||
class UserUsageInsights(BaseModel):
|
||||
most_used_model: Optional[str] = None
|
||||
average_tokens_per_chat: float = 0
|
||||
average_messages_per_active_day: float = 0
|
||||
user_message_share: float = 0
|
||||
assistant_message_share: float = 0
|
||||
|
||||
|
||||
class UserUsagePeriod(BaseModel):
|
||||
start_date: int
|
||||
end_date: int
|
||||
days: int
|
||||
|
||||
|
||||
class UserUsageResponse(BaseModel):
|
||||
totals: UserUsageTotals
|
||||
heatmap: list[UserUsageHeatmapEntry]
|
||||
weekly_heatmap: list[UserUsageHeatmapEntry]
|
||||
cumulative_heatmap: list[UserUsageHeatmapEntry]
|
||||
insights: UserUsageInsights
|
||||
top_models: list[UserUsageModelEntry]
|
||||
top_tools: list[UserUsageToolEntry] = []
|
||||
period: UserUsagePeriod
|
||||
|
||||
|
||||
def _week_start(date: datetime) -> datetime:
|
||||
return date - timedelta(days=date.weekday())
|
||||
|
||||
|
||||
def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]:
|
||||
weeks: dict[str, dict] = {}
|
||||
for day in heatmap:
|
||||
week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d')
|
||||
entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()})
|
||||
entry['messages'] += day.get('messages', 0)
|
||||
entry['chats'] += day.get('chats', 0)
|
||||
entry['tokens'] += day.get('tokens', 0)
|
||||
entry['models'].update(day.get('models', {}))
|
||||
|
||||
return [
|
||||
{
|
||||
**weeks[key],
|
||||
'models': dict(weeks[key]['models']),
|
||||
}
|
||||
for key in sorted(weeks)
|
||||
]
|
||||
|
||||
|
||||
def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]:
|
||||
totals = {'messages': 0, 'chats': 0, 'tokens': 0}
|
||||
models: Counter[str] = Counter()
|
||||
cumulative = []
|
||||
for day in heatmap:
|
||||
totals['messages'] += day.get('messages', 0)
|
||||
totals['chats'] += day.get('chats', 0)
|
||||
totals['tokens'] += day.get('tokens', 0)
|
||||
models.update(day.get('models', {}))
|
||||
cumulative.append(
|
||||
{
|
||||
'date': day['date'],
|
||||
**totals,
|
||||
'models': dict(models),
|
||||
}
|
||||
)
|
||||
return cumulative
|
||||
|
||||
|
||||
def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]:
|
||||
longest = 0
|
||||
current_run = 0
|
||||
for day in heatmap:
|
||||
if day.get('messages', 0) > 0:
|
||||
current_run += 1
|
||||
longest = max(longest, current_run)
|
||||
else:
|
||||
current_run = 0
|
||||
|
||||
current = 0
|
||||
for day in reversed(heatmap):
|
||||
if day.get('messages', 0) <= 0:
|
||||
break
|
||||
current += 1
|
||||
|
||||
return {'current': current, 'longest': longest}
|
||||
|
||||
|
||||
@router.get('/default/permissions', response_model=UserPermissions)
|
||||
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
|
||||
user_permissions = await Config.get('user.permissions')
|
||||
@@ -348,6 +478,23 @@ async def update_user_settings_by_session_user(
|
||||
# If the user is not an admin and does not have permission to use tool servers, remove the key
|
||||
updated_user_settings['ui'].pop('toolServers', None)
|
||||
|
||||
ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and (
|
||||
'notifications' in updated_user_settings
|
||||
or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
|
||||
)
|
||||
and not await has_permission(
|
||||
user.id,
|
||||
'features.webhooks',
|
||||
await Config.get('user.permissions'),
|
||||
)
|
||||
):
|
||||
updated_user_settings.pop('notifications', None)
|
||||
if isinstance(ui_notifications, dict):
|
||||
ui_notifications.pop('webhook_url', None)
|
||||
|
||||
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
|
||||
if user:
|
||||
await publish_event(
|
||||
@@ -428,6 +575,52 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
|
||||
return user.info
|
||||
|
||||
|
||||
class UserVariablesForm(BaseModel):
|
||||
variables: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserVariablesResponse(BaseModel):
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserVariablesBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/user/variables', response_model=UserVariablesResponse)
|
||||
async def get_user_variables_by_session_user(user=Depends(get_verified_user)):
|
||||
return UserVariablesResponse(variables=normalize_user_variables(user.variables))
|
||||
|
||||
|
||||
############################
|
||||
# UpdateUserVariablesBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.post('/user/variables/update', response_model=UserVariablesResponse)
|
||||
async def update_user_variables_by_session_user(
|
||||
form_data: UserVariablesForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
variables = validate_user_variables(form_data.variables)
|
||||
except ChatVariablesError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
return UserVariablesResponse(variables=variables)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateUserInfoBySessionUser
|
||||
############################
|
||||
@@ -455,6 +648,86 @@ async def update_user_info_by_session_user( # PATCH-style merge
|
||||
return updated.info
|
||||
|
||||
|
||||
############################
|
||||
# GetUserUsageBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/usage', response_model=UserUsageResponse)
|
||||
async def get_user_usage_by_session_user(
|
||||
days: Optional[int] = Query(None, ge=7, le=732),
|
||||
start_date: Optional[int] = Query(None),
|
||||
end_date: Optional[int] = Query(None),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
now = int(time.time())
|
||||
period_end = end_date or now
|
||||
if start_date is not None:
|
||||
period_start = start_date
|
||||
elif days is not None:
|
||||
period_start = period_end - ((days - 1) * 86400)
|
||||
else:
|
||||
period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400))
|
||||
|
||||
if period_start > period_end:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='start_date must be before end_date',
|
||||
)
|
||||
|
||||
period_days = max(1, int((period_end - period_start) / 86400) + 1)
|
||||
|
||||
lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db)
|
||||
period_summary = await ChatMessages.get_user_usage_summary(
|
||||
user.id, period_start, period_end, timezone=user.timezone, db=db
|
||||
)
|
||||
chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db)
|
||||
heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db)
|
||||
top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db)
|
||||
top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db)
|
||||
|
||||
streaks = _calculate_streaks(heatmap)
|
||||
total_messages = period_summary.get('messages', 0)
|
||||
total_chats = chat_stats.get('total_chats', 0)
|
||||
active_days = period_summary.get('active_days', 0)
|
||||
assistant_messages = period_summary.get('assistant_messages', 0)
|
||||
user_messages = period_summary.get('user_messages', 0)
|
||||
|
||||
return UserUsageResponse(
|
||||
totals=UserUsageTotals(
|
||||
lifetime_tokens=lifetime_summary.get('total_tokens', 0),
|
||||
input_tokens=lifetime_summary.get('input_tokens', 0),
|
||||
output_tokens=lifetime_summary.get('output_tokens', 0),
|
||||
peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0),
|
||||
longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0),
|
||||
current_streak=streaks['current'],
|
||||
longest_streak=streaks['longest'],
|
||||
total_chats=total_chats,
|
||||
active_days=active_days,
|
||||
models_used=lifetime_summary.get('models_used', 0),
|
||||
messages=total_messages,
|
||||
user_messages=user_messages,
|
||||
assistant_messages=assistant_messages,
|
||||
),
|
||||
heatmap=heatmap,
|
||||
weekly_heatmap=_build_weekly_heatmap(heatmap),
|
||||
cumulative_heatmap=_build_cumulative_heatmap(heatmap),
|
||||
insights=UserUsageInsights(
|
||||
most_used_model=top_models[0]['model_id'] if top_models else None,
|
||||
average_tokens_per_chat=(
|
||||
round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0
|
||||
),
|
||||
average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0,
|
||||
user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0,
|
||||
assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0,
|
||||
),
|
||||
top_models=top_models,
|
||||
top_tools=top_tools,
|
||||
period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserById
|
||||
############################
|
||||
@@ -796,36 +1069,41 @@ async def get_user_preview(
|
||||
user_group_ids = {g.id for g in user_groups}
|
||||
|
||||
all_models = await Models.get_all_models(db=db)
|
||||
accessible_model_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
active_models = [m for m in all_models if m.is_active]
|
||||
owned_model_ids = {m.id for m in active_models if m.user_id == user_id}
|
||||
granted_model_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_ids=[m.id for m in all_models],
|
||||
resource_ids=[m.id for m in active_models if m.user_id != user_id],
|
||||
permission='read',
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
accessible_model_ids = owned_model_ids | granted_model_ids
|
||||
|
||||
all_knowledge = await Knowledges.get_knowledge_bases(db=db)
|
||||
accessible_knowledge_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
owned_knowledge_ids = {k.id for k in all_knowledge if k.user_id == user_id}
|
||||
granted_knowledge_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_ids=[k.id for k in all_knowledge],
|
||||
resource_ids=[k.id for k in all_knowledge if k.user_id != user_id],
|
||||
permission='read',
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
accessible_knowledge_ids = owned_knowledge_ids | granted_knowledge_ids
|
||||
|
||||
all_tools = await Tools.get_tools(defer_content=True, db=db)
|
||||
accessible_tool_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
owned_tool_ids = {t.id for t in all_tools if t.user_id == user_id}
|
||||
granted_tool_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='tool',
|
||||
resource_ids=[t.id for t in all_tools],
|
||||
resource_ids=[t.id for t in all_tools if t.user_id != user_id],
|
||||
permission='read',
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
active_models = [m for m in all_models if m.is_active]
|
||||
accessible_tool_ids = owned_tool_ids | granted_tool_ids
|
||||
|
||||
return {
|
||||
'user': {'id': target_user.id, 'name': target_user.name},
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
import pycrdt as Y
|
||||
import socketio
|
||||
@@ -16,7 +15,6 @@ from open_webui.env import (
|
||||
ENABLE_WEBSOCKET_SUPPORT,
|
||||
GLOBAL_LOG_LEVEL,
|
||||
REDIS_KEY_PREFIX,
|
||||
VERSION,
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
||||
WEBSOCKET_MANAGER,
|
||||
WEBSOCKET_REDIS_CLUSTER,
|
||||
@@ -33,18 +31,21 @@ from open_webui.env import (
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.notes import Notes, NoteUpdateForm
|
||||
from open_webui.models.users import UserNameResponse, Users
|
||||
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
|
||||
from open_webui.tasks import create_task, stop_item_tasks
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import decode_token, is_valid_token
|
||||
from open_webui.utils.auth import get_verified_user_by_token
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
from open_webui.utils.json_codec import SOCKETIO_JSON
|
||||
from open_webui.utils.misc import get_output_text
|
||||
from open_webui.utils.redis import (
|
||||
build_sentinel_url,
|
||||
get_redis_connection,
|
||||
get_sentinels_from_env,
|
||||
)
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -57,6 +58,12 @@ REDIS = None
|
||||
# Configure CORS for Socket.IO
|
||||
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
|
||||
|
||||
|
||||
def get_room_sid_map(manager, namespace: str, room: str):
|
||||
"""Return this process's Socket.IO sid map for a room, without copying it."""
|
||||
return manager.rooms.get(namespace, {}).get(room)
|
||||
|
||||
|
||||
if WEBSOCKET_MANAGER == 'redis':
|
||||
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
|
||||
ws_redis_url = (
|
||||
@@ -64,10 +71,11 @@ if WEBSOCKET_MANAGER == 'redis':
|
||||
if sentinel_hosts
|
||||
else WEBSOCKET_REDIS_URL
|
||||
)
|
||||
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
|
||||
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS, json=SOCKETIO_JSON)
|
||||
sio = socketio.AsyncServer(
|
||||
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
||||
async_mode='asgi',
|
||||
json=SOCKETIO_JSON,
|
||||
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
||||
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
||||
always_connect=True,
|
||||
@@ -81,6 +89,7 @@ else:
|
||||
sio = socketio.AsyncServer(
|
||||
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
||||
async_mode='asgi',
|
||||
json=SOCKETIO_JSON,
|
||||
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
||||
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
||||
always_connect=True,
|
||||
@@ -166,25 +175,31 @@ YDOC_MANAGER = YdocManager(
|
||||
|
||||
async def periodic_session_pool_cleanup():
|
||||
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
|
||||
if not session_aquire_func():
|
||||
log.debug('Session cleanup lock held by another node. Skipping.')
|
||||
return
|
||||
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
|
||||
while True:
|
||||
if not session_aquire_func():
|
||||
log.debug('Session cleanup lock held by another node. Retrying.')
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
|
||||
try:
|
||||
while True:
|
||||
if not session_renew_func():
|
||||
log.error('Unable to renew session cleanup lock. Exiting.')
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
if not session_renew_func():
|
||||
log.warning('Unable to renew session cleanup lock. Retrying cleanup ownership.')
|
||||
break
|
||||
|
||||
now = int(time.time())
|
||||
for sid in list(SESSION_POOL.keys()):
|
||||
entry = SESSION_POOL.get(sid)
|
||||
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
|
||||
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
|
||||
del SESSION_POOL[sid]
|
||||
await asyncio.sleep(SESSION_POOL_TIMEOUT)
|
||||
finally:
|
||||
session_release_func()
|
||||
now = int(time.time())
|
||||
for sid in list(SESSION_POOL.keys()):
|
||||
entry = SESSION_POOL.get(sid)
|
||||
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
|
||||
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
|
||||
try:
|
||||
del SESSION_POOL[sid]
|
||||
except KeyError:
|
||||
pass
|
||||
await asyncio.sleep(SESSION_POOL_TIMEOUT)
|
||||
finally:
|
||||
session_release_func()
|
||||
|
||||
|
||||
async def periodic_usage_pool_cleanup():
|
||||
@@ -205,17 +220,19 @@ async def periodic_usage_pool_cleanup():
|
||||
try:
|
||||
while True:
|
||||
if not renew_func():
|
||||
log.error(f'Unable to renew cleanup lock. Exiting usage pool cleanup.')
|
||||
log.error('Unable to renew cleanup lock. Exiting usage pool cleanup.')
|
||||
raise Exception('Unable to renew usage pool cleanup lock.')
|
||||
|
||||
now = int(time.time())
|
||||
send_usage = False
|
||||
for model_id, connections in list(USAGE_POOL.items()):
|
||||
# Creating a list of sids to remove if they have timed out
|
||||
expired_sids = [
|
||||
sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION
|
||||
]
|
||||
|
||||
if connections and not expired_sids:
|
||||
continue
|
||||
|
||||
for sid in expired_sids:
|
||||
del connections[sid]
|
||||
|
||||
@@ -224,8 +241,6 @@ async def periodic_usage_pool_cleanup():
|
||||
del USAGE_POOL[model_id]
|
||||
else:
|
||||
USAGE_POOL[model_id] = connections
|
||||
|
||||
send_usage = True
|
||||
await asyncio.sleep(TIMEOUT_DURATION)
|
||||
finally:
|
||||
release_func()
|
||||
@@ -252,24 +267,21 @@ def get_user_id_from_session_pool(sid):
|
||||
|
||||
def get_session_ids_from_room(room):
|
||||
"""Get all session IDs from a specific room."""
|
||||
active_session_ids = sio.manager.get_participants(
|
||||
namespace='/',
|
||||
room=room,
|
||||
)
|
||||
return [session_id[0] for session_id in active_session_ids]
|
||||
members = get_room_sid_map(sio.manager, '/', room)
|
||||
return list(members) if members else []
|
||||
|
||||
|
||||
def get_user_ids_from_room(room):
|
||||
active_session_ids = get_session_ids_from_room(room)
|
||||
|
||||
# Single pool lookup per session (each .get is a Redis round trip
|
||||
# when the session pool is Redis-backed).
|
||||
active_user_ids = list(
|
||||
set(
|
||||
[
|
||||
SESSION_POOL.get(session_id)['id']
|
||||
for session_id in active_session_ids
|
||||
if SESSION_POOL.get(session_id) is not None
|
||||
]
|
||||
)
|
||||
{
|
||||
entry['id']
|
||||
for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids)
|
||||
if entry is not None
|
||||
}
|
||||
)
|
||||
return active_user_ids
|
||||
|
||||
@@ -333,7 +345,7 @@ async def usage(sid, data):
|
||||
|
||||
# Store the new usage data and task
|
||||
USAGE_POOL[model_id] = {
|
||||
**(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}),
|
||||
**(USAGE_POOL.get(model_id) or {}),
|
||||
sid: {'updated_at': current_time},
|
||||
}
|
||||
|
||||
@@ -345,13 +357,10 @@ async def connect(sid, environ, auth):
|
||||
scope = (environ or {}).get('asgi.scope') or {}
|
||||
fastapi_app = scope.get('app')
|
||||
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
|
||||
data = decode_token(auth['token'])
|
||||
|
||||
if data is not None and 'id' in data and await is_valid_token(data, redis):
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
user = await get_verified_user_by_token(auth['token'], redis)
|
||||
|
||||
if user:
|
||||
SESSION_POOL[sid] = {
|
||||
socket_user = {
|
||||
**user.model_dump(
|
||||
exclude=[
|
||||
'profile_image_url',
|
||||
@@ -363,6 +372,8 @@ async def connect(sid, environ, auth):
|
||||
),
|
||||
'last_seen_at': int(time.time()),
|
||||
}
|
||||
SESSION_POOL[sid] = socket_user
|
||||
await sio.save_session(sid, {'user': socket_user})
|
||||
await sio.enter_room(sid, f'user:{user.id}')
|
||||
|
||||
|
||||
@@ -376,15 +387,11 @@ async def user_join(sid, data):
|
||||
scope = environ.get('asgi.scope') or {}
|
||||
fastapi_app = scope.get('app')
|
||||
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
|
||||
token_data = decode_token(auth['token'])
|
||||
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(token_data['id'])
|
||||
user = await get_verified_user_by_token(auth['token'], redis)
|
||||
if not user:
|
||||
return
|
||||
|
||||
SESSION_POOL[sid] = {
|
||||
socket_user = {
|
||||
**user.model_dump(
|
||||
exclude=[
|
||||
'profile_image_url',
|
||||
@@ -397,6 +404,8 @@ async def user_join(sid, data):
|
||||
'last_seen_at': int(time.time()),
|
||||
}
|
||||
|
||||
SESSION_POOL[sid] = socket_user
|
||||
await sio.save_session(sid, {'user': socket_user})
|
||||
await sio.enter_room(sid, f'user:{user.id}')
|
||||
|
||||
# Join all the channels only if user has channels permission
|
||||
@@ -427,11 +436,7 @@ async def join_channel(sid, data):
|
||||
scope = environ.get('asgi.scope') or {}
|
||||
fastapi_app = scope.get('app')
|
||||
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
|
||||
data = decode_token(auth['token'])
|
||||
if data is None or 'id' not in data or not await is_valid_token(data, redis):
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
user = await get_verified_user_by_token(auth['token'], redis)
|
||||
if not user:
|
||||
return
|
||||
|
||||
@@ -453,11 +458,7 @@ async def join_note(sid, data):
|
||||
scope = environ.get('asgi.scope') or {}
|
||||
fastapi_app = scope.get('app')
|
||||
redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS
|
||||
token_data = decode_token(auth['token'])
|
||||
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(token_data['id'])
|
||||
user = await get_verified_user_by_token(auth['token'], redis)
|
||||
if not user:
|
||||
return
|
||||
|
||||
@@ -486,13 +487,7 @@ async def join_note(sid, data):
|
||||
@sio.on('events:channel')
|
||||
async def channel_events(sid, data):
|
||||
room = f'channel:{data["channel_id"]}'
|
||||
participants = sio.manager.get_participants(
|
||||
namespace='/',
|
||||
room=room,
|
||||
)
|
||||
|
||||
sids = [sid for sid, _ in participants]
|
||||
if sid not in sids:
|
||||
if sid not in (get_room_sid_map(sio.manager, '/', room) or {}):
|
||||
return
|
||||
|
||||
event_data = data['data']
|
||||
@@ -518,9 +513,35 @@ async def channel_events(sid, data):
|
||||
await Channels.update_member_last_read_at(data['channel_id'], user['id'])
|
||||
|
||||
|
||||
async def get_folder_unread_counts(user_id: str) -> dict[str, int]:
|
||||
folder_list = await Folders.get_folders_by_user_id(user_id)
|
||||
parent_by_id = {folder.id: folder.parent_id for folder in folder_list}
|
||||
unread_counts = dict.fromkeys(parent_by_id.keys(), 0)
|
||||
|
||||
direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()))
|
||||
for unread_folder_id, unread_count in direct_unread_counts.items():
|
||||
current_id = unread_folder_id
|
||||
seen = set()
|
||||
while current_id and current_id not in seen:
|
||||
seen.add(current_id)
|
||||
if current_id in unread_counts:
|
||||
unread_counts[current_id] += unread_count
|
||||
current_id = parent_by_id.get(current_id)
|
||||
|
||||
return unread_counts
|
||||
|
||||
|
||||
@sio.on('events:chat')
|
||||
async def chat_events(sid, data):
|
||||
user = SESSION_POOL.get(sid)
|
||||
try:
|
||||
session = await sio.get_session(sid)
|
||||
user = session.get('user')
|
||||
except KeyError:
|
||||
user = None
|
||||
|
||||
if not user:
|
||||
user = SESSION_POOL.get(sid)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
@@ -528,7 +549,34 @@ async def chat_events(sid, data):
|
||||
event_type = event_data.get('type')
|
||||
|
||||
if event_type == 'last_read_at':
|
||||
await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
|
||||
read_update = await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
|
||||
if not read_update:
|
||||
return
|
||||
last_read_at, was_unread = read_update
|
||||
response_data = {
|
||||
'chat_id': data['chat_id'],
|
||||
'last_read_at': last_read_at,
|
||||
}
|
||||
if was_unread:
|
||||
response_data['folder_unread_counts'] = await get_folder_unread_counts(user['id'])
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': data['chat_id'],
|
||||
'data': {
|
||||
'type': 'chat:list',
|
||||
'data': response_data,
|
||||
},
|
||||
},
|
||||
room=f'user:{user["id"]}',
|
||||
)
|
||||
try:
|
||||
from open_webui.utils.timers import cancel_timers_for_chat
|
||||
|
||||
await cancel_timers_for_chat(data['chat_id'], 'chat.read', user['id'])
|
||||
except Exception:
|
||||
log.exception('Failed to cancel chat.read timers for chat %s', data.get('chat_id'))
|
||||
|
||||
|
||||
def normalize_document_id(document_id: str) -> str:
|
||||
@@ -829,7 +877,6 @@ async def yjs_awareness_update(sid, data):
|
||||
@sio.event
|
||||
async def disconnect(sid, reason=None):
|
||||
if sid in SESSION_POOL:
|
||||
user = SESSION_POOL[sid]
|
||||
del SESSION_POOL[sid]
|
||||
|
||||
# Clean up USAGE_POOL entries for this session
|
||||
@@ -860,19 +907,20 @@ async def _make_channel_emitter(request_info):
|
||||
state = {'last_emit_at': 0.0}
|
||||
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
|
||||
|
||||
async def _emit_channel_update(content: str, done: bool = False):
|
||||
async def _emit_channel_update(content: str, done: bool = False, output: list | None = None):
|
||||
from open_webui.models.messages import MessageForm, Messages
|
||||
|
||||
msg = await Messages.get_message_by_id(message_id)
|
||||
if not msg or msg.channel_id != channel_id:
|
||||
return
|
||||
|
||||
update_form = MessageForm(content=content)
|
||||
update_form = MessageForm(content=content, data={'output': output} if output else None)
|
||||
if done:
|
||||
# Merge done flag into existing meta (preserve model_id etc.)
|
||||
existing_meta = msg.meta or {}
|
||||
update_form = MessageForm(
|
||||
content=content,
|
||||
data={'output': output} if output else None,
|
||||
meta={**existing_meta, 'done': True},
|
||||
)
|
||||
|
||||
@@ -897,16 +945,17 @@ async def _make_channel_emitter(request_info):
|
||||
|
||||
if event_type == 'chat:completion':
|
||||
data = event_data.get('data', {})
|
||||
content = data.get('content', '')
|
||||
output = data.get('output')
|
||||
content = data.get('content') or get_output_text(output)
|
||||
done = data.get('done', False)
|
||||
|
||||
if not content and not done:
|
||||
if not content and not output and not done:
|
||||
return
|
||||
|
||||
now = __import__('time').time()
|
||||
if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL:
|
||||
state['last_emit_at'] = now
|
||||
await _emit_channel_update(content, done)
|
||||
await _emit_channel_update(content, done, output if isinstance(output, list) else None)
|
||||
|
||||
elif event_type == 'chat:message:error':
|
||||
error = event_data.get('data', {}).get('error', {})
|
||||
@@ -925,18 +974,27 @@ async def get_event_emitter(request_info, update_db=True):
|
||||
user_id = request_info['user_id']
|
||||
chat_id = request_info['chat_id']
|
||||
message_id = request_info['message_id']
|
||||
internal = request_info.get('internal') is True
|
||||
save_to_chat = update_db and message_id and is_saved_chat_id(chat_id)
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': chat_id,
|
||||
'message_id': message_id,
|
||||
'data': event_data,
|
||||
},
|
||||
room=f'user:{user_id}',
|
||||
)
|
||||
if internal and event_data.get('type') == 'notification':
|
||||
return
|
||||
|
||||
if update_db and message_id and not (request_info.get('chat_id') or '').startswith('local:'):
|
||||
room = f'user:{user_id}'
|
||||
# Local rooms are authoritative; Redis may have listeners on another instance.
|
||||
if WEBSOCKET_MANAGER == 'redis' or room in sio.manager.rooms.get('/', {}):
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': chat_id,
|
||||
'message_id': message_id,
|
||||
**({'internal': True} if internal else {}),
|
||||
'data': event_data,
|
||||
},
|
||||
room=room,
|
||||
)
|
||||
|
||||
if save_to_chat:
|
||||
event_type = event_data.get('type')
|
||||
|
||||
if event_type == 'status':
|
||||
@@ -992,6 +1050,7 @@ async def get_event_emitter(request_info, update_db=True):
|
||||
{
|
||||
'embeds': embeds,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
elif event_type == 'files':
|
||||
@@ -1009,6 +1068,7 @@ async def get_event_emitter(request_info, update_db=True):
|
||||
{
|
||||
'files': files,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
elif event_type in ('source', 'citation'):
|
||||
@@ -1028,6 +1088,7 @@ async def get_event_emitter(request_info, update_db=True):
|
||||
{
|
||||
'sources': sources,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
if 'user_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
|
||||
@@ -1059,6 +1120,11 @@ async def get_event_call(request_info):
|
||||
)
|
||||
except TimeoutError:
|
||||
log.warning(f'Event caller timed out for session {session_id}')
|
||||
if SESSION_POOL.get(session_id) == session:
|
||||
try:
|
||||
del SESSION_POOL[session_id]
|
||||
except KeyError:
|
||||
pass
|
||||
return {'error': 'Event call timed out. The browser tab may be inactive or closed.'}
|
||||
|
||||
if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pycrdt as Y
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
from open_webui.env import REDIS_KEY_PREFIX
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
|
||||
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
|
||||
|
||||
@@ -16,6 +16,19 @@ YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
|
||||
class RedisLock:
|
||||
"""Distributed lock backed by a Redis SET with NX/EX semantics."""
|
||||
|
||||
_RENEW_SCRIPT = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('expire', KEYS[1], ARGV[2])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
_RELEASE_SCRIPT = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url,
|
||||
@@ -41,13 +54,10 @@ class RedisLock:
|
||||
return self.lock_obtained
|
||||
|
||||
def renew_lock(self):
|
||||
# xx=True will only set this key if it _has_ already been set
|
||||
return self.redis.set(self.lock_name, self.lock_id, xx=True, ex=self.timeout_secs)
|
||||
return bool(self.redis.eval(self._RENEW_SCRIPT, 1, self.lock_name, self.lock_id, self.timeout_secs))
|
||||
|
||||
def release_lock(self):
|
||||
lock_value = self.redis.get(self.lock_name)
|
||||
if lock_value and lock_value == self.lock_id:
|
||||
self.redis.delete(self.lock_name)
|
||||
self.redis.eval(self._RELEASE_SCRIPT, 1, self.lock_name, self.lock_id)
|
||||
|
||||
|
||||
class RedisDict:
|
||||
@@ -65,14 +75,14 @@ class RedisDict:
|
||||
)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
serialized_value = json.dumps(value)
|
||||
serialized_value = JSONCodec.dumps(value)
|
||||
self.redis.hset(self.name, key, serialized_value)
|
||||
|
||||
def __getitem__(self, key):
|
||||
value = self.redis.hget(self.name, key)
|
||||
if value is None:
|
||||
raise KeyError(key)
|
||||
return json.loads(value)
|
||||
return JSONCodec.loads(value)
|
||||
|
||||
def __delitem__(self, key):
|
||||
result = self.redis.hdel(self.name, key)
|
||||
@@ -89,10 +99,10 @@ class RedisDict:
|
||||
return self.redis.hkeys(self.name)
|
||||
|
||||
def values(self):
|
||||
return [json.loads(v) for v in self.redis.hvals(self.name)]
|
||||
return [JSONCodec.loads(v) for v in self.redis.hvals(self.name)]
|
||||
|
||||
def items(self):
|
||||
return [(k, json.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
|
||||
return [(k, JSONCodec.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
|
||||
|
||||
def set(self, mapping: dict):
|
||||
if not mapping:
|
||||
@@ -101,13 +111,19 @@ class RedisDict:
|
||||
return
|
||||
|
||||
# Serialize values once — reused for both the fingerprint and the write.
|
||||
serialized = {k: json.dumps(v) for k, v in mapping.items()}
|
||||
serialized = {k: JSONCodec.dumps(v) for k, v in mapping.items()}
|
||||
digest = hashlib.sha256()
|
||||
for key in sorted(serialized):
|
||||
digest.update(key.encode())
|
||||
digest.update(b'\0')
|
||||
digest.update(serialized[key].encode())
|
||||
digest.update(b'\0')
|
||||
signature = digest.hexdigest()
|
||||
|
||||
# Skip the write when the prepared mapping is identical to the last one
|
||||
# this process wrote. The check is per-instance (not distributed), but
|
||||
# still eliminates the majority of redundant writes because each pod
|
||||
# typically produces the same model list on consecutive refreshes.
|
||||
signature = hashlib.sha256(json.dumps(serialized, sort_keys=True).encode()).hexdigest()
|
||||
if signature == self._last_signature:
|
||||
return
|
||||
|
||||
@@ -166,7 +182,7 @@ class YdocManager:
|
||||
document_id = document_id.replace(':', '_')
|
||||
if self._redis:
|
||||
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
||||
await self._redis.rpush(redis_key, json.dumps(list(update)))
|
||||
await self._redis.rpush(redis_key, JSONCodec.dumps(list(update)))
|
||||
list_len = await self._redis.llen(redis_key)
|
||||
if list_len >= self.COMPACTION_THRESHOLD:
|
||||
await self._compact_updates_redis(document_id)
|
||||
@@ -186,8 +202,8 @@ class YdocManager:
|
||||
mid = len(all_updates) // 2
|
||||
ydoc = Y.Doc()
|
||||
for raw in all_updates[:mid]:
|
||||
ydoc.apply_update(bytes(json.loads(raw)))
|
||||
snapshot = json.dumps(list(ydoc.get_update()))
|
||||
ydoc.apply_update(bytes(JSONCodec.loads(raw)))
|
||||
snapshot = JSONCodec.dumps(list(ydoc.get_update()))
|
||||
pipe = self._redis.pipeline()
|
||||
pipe.delete(redis_key)
|
||||
pipe.rpush(redis_key, snapshot, *all_updates[mid:])
|
||||
@@ -210,7 +226,7 @@ class YdocManager:
|
||||
if self._redis:
|
||||
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
||||
updates = await self._redis.lrange(redis_key, 0, -1)
|
||||
return [bytes(json.loads(update)) for update in updates]
|
||||
return [bytes(JSONCodec.loads(update)) for update in updates]
|
||||
else:
|
||||
return self._updates.get(document_id, [])
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -2,10 +2,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from open_webui.env import REDIS_KEY_PREFIX
|
||||
@@ -13,7 +11,7 @@ from open_webui.env import REDIS_KEY_PREFIX
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A dictionary to keep track of active tasks
|
||||
tasks: Dict[str, asyncio.Task] = {}
|
||||
tasks: dict[str, asyncio.Task] = {}
|
||||
item_tasks = {}
|
||||
|
||||
|
||||
@@ -46,7 +44,7 @@ async def redis_task_command_listener(app):
|
||||
### ------------------------------
|
||||
|
||||
|
||||
async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||
async def redis_save_task(redis: Redis, task_id: str, item_id: str | None):
|
||||
pipe = redis.pipeline()
|
||||
pipe.hset(REDIS_TASKS_KEY, task_id, item_id or '')
|
||||
if item_id:
|
||||
@@ -54,7 +52,7 @@ async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||
await pipe.execute()
|
||||
|
||||
|
||||
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]):
|
||||
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None):
|
||||
pipe = redis.pipeline()
|
||||
pipe.hdel(REDIS_TASKS_KEY, task_id)
|
||||
if item_id:
|
||||
@@ -67,11 +65,11 @@ async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str])
|
||||
await pipe.execute()
|
||||
|
||||
|
||||
async def redis_list_tasks(redis: Redis) -> List[str]:
|
||||
async def redis_list_tasks(redis: Redis) -> list[str]:
|
||||
return list(await redis.hkeys(REDIS_TASKS_KEY))
|
||||
|
||||
|
||||
async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]:
|
||||
async def redis_list_item_tasks(redis: Redis, item_id: str) -> list[str]:
|
||||
return list(await redis.smembers(f'{REDIS_ITEM_TASKS_KEY}:{item_id}'))
|
||||
|
||||
|
||||
@@ -101,11 +99,11 @@ async def cleanup_task(redis, task_id: str, id=None):
|
||||
item_tasks.pop(id, None)
|
||||
|
||||
|
||||
async def create_task(redis, coroutine, id=None):
|
||||
async def create_task(redis, coroutine, id=None, task_id=None):
|
||||
"""
|
||||
Create a new asyncio task and add it to the global task dictionary.
|
||||
"""
|
||||
task_id = str(uuid4()) # Generate a unique ID for the task
|
||||
task_id = task_id or str(uuid4()) # Generate a unique ID for the task
|
||||
task = asyncio.create_task(coroutine) # Create the task
|
||||
|
||||
# Add a done callback for cleanup
|
||||
@@ -199,12 +197,3 @@ async def has_active_tasks(redis, chat_id: str) -> bool:
|
||||
"""Check if a chat has any active tasks."""
|
||||
task_ids = await list_task_ids_by_item_id(redis, chat_id)
|
||||
return len(task_ids) > 0
|
||||
|
||||
|
||||
async def get_active_chat_ids(redis, chat_ids: List[str]) -> List[str]:
|
||||
"""Filter a list of chat_ids to only those with active tasks."""
|
||||
active = []
|
||||
for chat_id in chat_ids:
|
||||
if await has_active_tasks(redis, chat_id):
|
||||
active.append(chat_id)
|
||||
return active
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,24 +7,58 @@ for AI models to interact with knowledge bases using commands they already know.
|
||||
Re-exported through builtin.py for consistent imports.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
import regex
|
||||
from fastapi import Request
|
||||
|
||||
from open_webui.env import (
|
||||
KB_EXEC_MAX_GREP_FILES,
|
||||
KB_EXEC_MAX_OUTPUT_CHARS,
|
||||
KNOWLEDGE_GREP_MAX_MATCHES,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Limits
|
||||
MAX_CAT_CHARS = 100_000
|
||||
DEFAULT_CAT_CHARS = 10_000
|
||||
MAX_GREP_FILES = 200
|
||||
DEFAULT_HEAD_LINES = 10
|
||||
DEFAULT_TAIL_LINES = 10
|
||||
MAX_GREP_MATCHES = 50
|
||||
|
||||
# Matching time allowed per tool call. Backtracking cost is exponential in the length of the
|
||||
# matched text, so capping the pattern or the line does not bound it.
|
||||
MATCH_BUDGET_SECONDS = 2.0
|
||||
|
||||
|
||||
class MatchBudgetExceeded(Exception):
|
||||
"""A tool call spent its whole matching budget, so the caller reports it."""
|
||||
|
||||
|
||||
class MatchBudget:
|
||||
"""Matching time remaining, counted only inside search() so awaits do not consume it."""
|
||||
|
||||
def __init__(self):
|
||||
self.remaining = MATCH_BUDGET_SECONDS
|
||||
|
||||
|
||||
# Scoped to the running task, so one budget covers every matcher a command builds without
|
||||
# threading it through each handler.
|
||||
_active_budget: contextvars.ContextVar[MatchBudget | None] = contextvars.ContextVar('kb_match_budget', default=None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def match_budget():
|
||||
"""Bound the matching time of one tool call rather than of each search it runs."""
|
||||
token = _active_budget.set(MatchBudget())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_active_budget.reset(token)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -33,9 +67,9 @@ MAX_GREP_MATCHES = 50
|
||||
|
||||
|
||||
def is_regex_pattern(pattern: str) -> bool:
|
||||
"""Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...])."""
|
||||
"""Detect if a pattern looks like regex (|, .*, .+, \d, \w, \s, [...])."""
|
||||
return (
|
||||
'\|' in pattern
|
||||
'|' in pattern
|
||||
or '.*' in pattern
|
||||
or '.+' in pattern
|
||||
or '.?' in pattern
|
||||
@@ -59,11 +93,26 @@ def build_matcher(pattern: str, case_insensitive: bool = False, use_regex: bool
|
||||
if use_regex:
|
||||
normalized = normalize_regex(pattern)
|
||||
try:
|
||||
re_flags = re.IGNORECASE if case_insensitive else 0
|
||||
compiled = re.compile(normalized, re_flags)
|
||||
except re.error as e:
|
||||
re_flags = regex.IGNORECASE if case_insensitive else 0
|
||||
compiled = regex.compile(normalized, re_flags)
|
||||
except regex.error as e:
|
||||
return None, f'Invalid regex: {e}'
|
||||
return (lambda line: bool(compiled.search(line))), None
|
||||
|
||||
budget = _active_budget.get() or MatchBudget()
|
||||
|
||||
def matches(line: str) -> bool:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
# A negative timeout disables it, so an exhausted budget must not reach search().
|
||||
if budget.remaining <= 0:
|
||||
raise TimeoutError
|
||||
return bool(compiled.search(line, timeout=budget.remaining))
|
||||
except TimeoutError:
|
||||
raise MatchBudgetExceeded(f'Search exceeded {MATCH_BUDGET_SECONDS:g}s, narrow the pattern') from None
|
||||
finally:
|
||||
budget.remaining -= time.monotonic() - started
|
||||
|
||||
return matches, None
|
||||
else:
|
||||
sp = pattern.lower() if case_insensitive else pattern
|
||||
return (lambda line: sp in (line.lower() if case_insensitive else line)), None
|
||||
@@ -579,21 +628,10 @@ async def _kb_cat(args: list[str], flags: set[str], user: dict, model_knowledge:
|
||||
return resolved['error']
|
||||
|
||||
content = resolved['content']
|
||||
show_numbers = 'n' in flags
|
||||
|
||||
if len(content) > MAX_CAT_CHARS:
|
||||
content = content[:MAX_CAT_CHARS]
|
||||
truncated = True
|
||||
else:
|
||||
truncated = False
|
||||
|
||||
if show_numbers:
|
||||
if 'n' in flags:
|
||||
lines = content.split('\n')
|
||||
content = '\n'.join(f'{i}: {line}' for i, line in enumerate(lines, 1))
|
||||
|
||||
if truncated:
|
||||
content += f'\n[truncated at {MAX_CAT_CHARS:,} chars — use head/tail/sed/grep to navigate]'
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@@ -686,12 +724,16 @@ async def _kb_grep(
|
||||
|
||||
# Grep on piped input
|
||||
if piped_input is not None:
|
||||
lines = piped_input.split('\\n')
|
||||
lines = piped_input.split('\n')
|
||||
matched = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
matched.append(f'{i}: {line}')
|
||||
return '\\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
if count_only:
|
||||
return str(len(matched))
|
||||
if filenames_only:
|
||||
return '(standard input)' if matched else f'No matches for "{pattern}"'
|
||||
return '\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
|
||||
# Single file grep
|
||||
if file_ref and not dir_scope:
|
||||
@@ -702,7 +744,7 @@ async def _kb_grep(
|
||||
elif 'error' in resolved:
|
||||
return resolved['error']
|
||||
else:
|
||||
lines = resolved['content'].split('\\n')
|
||||
lines = resolved['content'].split('\n')
|
||||
matched = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
@@ -715,7 +757,7 @@ async def _kb_grep(
|
||||
|
||||
if not matched:
|
||||
return f'No matches for "{pattern}" in {resolved["filename"]}'
|
||||
return '\\n'.join(matched)
|
||||
return '\n'.join(matched)
|
||||
|
||||
# Cross-file grep (optionally scoped to directory)
|
||||
accessible = await _get_accessible_files(user, model_knowledge)
|
||||
@@ -738,7 +780,7 @@ async def _kb_grep(
|
||||
if ext_filter:
|
||||
accessible = [f for f in accessible if f['filename'].endswith(f'.{ext_filter}')]
|
||||
|
||||
if len(accessible) > MAX_GREP_FILES:
|
||||
if len(accessible) > KB_EXEC_MAX_GREP_FILES:
|
||||
return f'Too many files ({len(accessible)}). Scope your search: grep "{pattern}" docs/ or grep "{pattern}" *.py'
|
||||
|
||||
from open_webui.models.files import Files
|
||||
@@ -770,7 +812,7 @@ async def _kb_grep(
|
||||
|
||||
if not count_only and not filenames_only:
|
||||
for line_num, line_text in file_matches:
|
||||
if len(results) < MAX_GREP_MATCHES:
|
||||
if len(results) < KNOWLEDGE_GREP_MAX_MATCHES:
|
||||
results.append(f'{file_info["id"]} {file_info["filename"]}:{line_num}: {line_text.rstrip()}')
|
||||
|
||||
if count_only:
|
||||
@@ -789,8 +831,8 @@ async def _kb_grep(
|
||||
return f'No matches for "{pattern}" across {len(accessible)} files'
|
||||
|
||||
output = '\n'.join(results)
|
||||
if total_matches > MAX_GREP_MATCHES:
|
||||
output += f'\n[showing {MAX_GREP_MATCHES} of {total_matches} matches]'
|
||||
if total_matches > KNOWLEDGE_GREP_MAX_MATCHES:
|
||||
output += f'\n[showing {KNOWLEDGE_GREP_MAX_MATCHES} of {total_matches} matches]'
|
||||
return output
|
||||
|
||||
|
||||
@@ -1127,7 +1169,15 @@ async def kb_exec(
|
||||
if not segments:
|
||||
return 'Could not parse command. Run kb_exec("ls") to start.'
|
||||
|
||||
return await _execute_pipeline(segments, __user__, __model_knowledge__)
|
||||
# One budget for the whole command: a per-search budget would multiply by segment count.
|
||||
with match_budget():
|
||||
output = await _execute_pipeline(segments, __user__, __model_knowledge__)
|
||||
if len(output) > KB_EXEC_MAX_OUTPUT_CHARS:
|
||||
output = output[:KB_EXEC_MAX_OUTPUT_CHARS] + (
|
||||
f'\n[output truncated at {KB_EXEC_MAX_OUTPUT_CHARS:,} chars'
|
||||
' — narrow the command with a path, glob, head/tail/sed or grep]'
|
||||
)
|
||||
return output
|
||||
except Exception as e:
|
||||
log.exception(f'kb_exec error: {e}')
|
||||
return f'Error: {e}'
|
||||
|
||||
@@ -3,9 +3,11 @@ from typing import Any
|
||||
|
||||
from open_webui.config import DEFAULT_USER_PERMISSIONS
|
||||
from open_webui.models.access_grants import (
|
||||
has_anyone_read_access_grant,
|
||||
has_public_read_access_grant,
|
||||
has_public_write_access_grant,
|
||||
has_user_access_grant,
|
||||
strip_anyone_access_grants,
|
||||
strip_user_access_grants,
|
||||
)
|
||||
from open_webui.models.groups import Groups
|
||||
@@ -161,10 +163,16 @@ async def has_connection_access(
|
||||
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return True
|
||||
|
||||
access_grants = (connection.get('config') or {}).get('access_grants', [])
|
||||
if not access_grants:
|
||||
# No grants configured → private, admin-only: admins must keep access
|
||||
# to connections only they can configure, even when they do not bypass
|
||||
# access control globally.
|
||||
return user.role == 'admin'
|
||||
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
|
||||
access_grants = (connection.get('config') or {}).get('access_grants', [])
|
||||
return await has_access(user.id, 'read', access_grants, user_group_ids)
|
||||
|
||||
|
||||
@@ -215,13 +223,31 @@ async def filter_allowed_access_grants(
|
||||
user_role: str,
|
||||
access_grants: list,
|
||||
public_permission_key: str,
|
||||
anyone_permission_key: str | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Checks if the user has the required permissions to grant access to a resource.
|
||||
Returns the filtered list of access grants if permissions are missing.
|
||||
"""
|
||||
if user_role == 'admin' or not access_grants:
|
||||
if not access_grants:
|
||||
return access_grants
|
||||
|
||||
if has_anyone_read_access_grant(access_grants) and (
|
||||
not anyone_permission_key
|
||||
or (
|
||||
user_role != 'admin'
|
||||
and not await has_permission(
|
||||
user_id,
|
||||
anyone_permission_key,
|
||||
default_permissions,
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
):
|
||||
access_grants = strip_anyone_access_grants(access_grants)
|
||||
|
||||
if user_role == 'admin':
|
||||
return access_grants
|
||||
|
||||
# Check if user can share publicly
|
||||
@@ -253,6 +279,22 @@ async def filter_allowed_access_grants(
|
||||
):
|
||||
access_grants = strip_user_access_grants(access_grants)
|
||||
|
||||
if any(
|
||||
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group'
|
||||
for grant in access_grants
|
||||
) and not await has_permission(
|
||||
user_id,
|
||||
'access_grants.allow_groups',
|
||||
default_permissions,
|
||||
db=db,
|
||||
):
|
||||
access_grants = [
|
||||
grant
|
||||
for grant in access_grants
|
||||
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
|
||||
!= 'group'
|
||||
]
|
||||
|
||||
return access_grants
|
||||
|
||||
|
||||
@@ -260,6 +302,7 @@ async def has_base_model_access(
|
||||
user_id: str,
|
||||
model_info,
|
||||
*,
|
||||
user_role: str | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
db=None,
|
||||
) -> bool:
|
||||
@@ -267,9 +310,11 @@ async def has_base_model_access(
|
||||
Walk the ``base_model_id`` chain and verify the caller has read access
|
||||
at every hop.
|
||||
|
||||
Returns ``True`` when access is granted (or the chain ends at a raw
|
||||
provider model that has no per-model ACL). Returns ``False`` the
|
||||
moment a registered base model denies access.
|
||||
A base model without a ``model`` table row is admin-only, matching how
|
||||
unregistered models are treated for direct use (``get_filtered_models``
|
||||
hides them from non-admins and ``check_model_access`` rejects them), so
|
||||
a shared preset cannot be used to reach a base model the caller could
|
||||
not use directly. Returns ``False`` the moment any hop denies access.
|
||||
"""
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.models import Models
|
||||
@@ -280,7 +325,7 @@ async def has_base_model_access(
|
||||
seen.add(base_model_id)
|
||||
base_model_info = await Models.get_model_by_id(base_model_id, db=db)
|
||||
if base_model_info is None:
|
||||
break # Raw provider model — no per-model ACL
|
||||
return user_role == 'admin'
|
||||
if not (
|
||||
user_id == base_model_info.user_id
|
||||
or await AccessGrants.has_access(
|
||||
@@ -339,7 +384,7 @@ async def check_model_access(
|
||||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
|
||||
# Enforce access on chained base models
|
||||
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids):
|
||||
if not await has_base_model_access(user.id, model_info, user_role=user.role, user_group_ids=user_group_ids):
|
||||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
else:
|
||||
if user.role != 'admin':
|
||||
|
||||
@@ -4,20 +4,24 @@ from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.folders import FolderModel
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.users import UserModel, Users
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FOLDER_FILE_TYPES = {'file', 'collection', 'note'}
|
||||
|
||||
|
||||
async def has_access_to_file(
|
||||
file_id: str | None,
|
||||
access_type: str,
|
||||
user: UserModel,
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has the specified access to a file through any of:
|
||||
@@ -43,7 +47,8 @@ async def has_access_to_file(
|
||||
# the object's OWNER owns that file; otherwise a read-only file laundered into an object
|
||||
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
|
||||
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if (
|
||||
knowledge_base.user_id == user.id
|
||||
@@ -60,12 +65,25 @@ async def has_access_to_file(
|
||||
|
||||
knowledge_base_id = file.meta.get('collection_name') if file.meta else None
|
||||
if knowledge_base_id:
|
||||
knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db)
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.id == knowledge_base_id and (
|
||||
access_type == 'read' or knowledge_base.user_id == file.user_id
|
||||
):
|
||||
return True
|
||||
# Fetch the one referenced knowledge base instead of listing every
|
||||
# knowledge base the user can access just to scan for this id.
|
||||
knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id, db=db)
|
||||
if (
|
||||
knowledge_base
|
||||
and (access_type == 'read' or knowledge_base.user_id == file.user_id)
|
||||
and (
|
||||
knowledge_base.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge_base.id,
|
||||
permission=access_type,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any channels the user has access to
|
||||
channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
|
||||
@@ -88,7 +106,9 @@ async def has_access_to_file(
|
||||
|
||||
# Check if the file is directly attached to a shared workspace model (per the ownership
|
||||
# note above, model write is conferred only for files the model owner owns).
|
||||
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
|
||||
for model in await Models.get_models_by_user_id(
|
||||
user.id, permission=access_type, db=db, user_group_ids=user_group_ids
|
||||
):
|
||||
knowledge_items = getattr(model.meta, 'knowledge', None) or []
|
||||
for item in knowledge_items:
|
||||
if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:
|
||||
@@ -105,29 +125,74 @@ async def get_accessible_folder_files(
|
||||
) -> list[dict]:
|
||||
"""Filter folder.data['files'] entries to those the caller can read.
|
||||
|
||||
Each entry is expected to have 'type' ('file' or 'collection') and 'id'.
|
||||
Admins bypass all checks. Unknown types are kept as-is.
|
||||
Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. Entries of any other
|
||||
shape are dropped because they cannot be access-checked.
|
||||
"""
|
||||
if not entries:
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and entry.get('type') in FOLDER_FILE_TYPES and entry.get('id')
|
||||
]
|
||||
if user.role == 'admin':
|
||||
return list(entries)
|
||||
return entries
|
||||
|
||||
# One group-membership fetch for the whole folder listing
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
|
||||
accessible: list[dict] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_type = entry.get('type')
|
||||
entry_id = entry.get('id')
|
||||
if not entry_id:
|
||||
accessible.append(entry)
|
||||
continue
|
||||
if entry_type == 'file':
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db):
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids):
|
||||
accessible.append(entry)
|
||||
elif entry_type == 'collection':
|
||||
if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db):
|
||||
accessible.append(entry)
|
||||
else:
|
||||
accessible.append(entry)
|
||||
elif entry_type == 'note':
|
||||
# Owner has no self-grant (notes are private by default), so check ownership too.
|
||||
from open_webui.models.notes import Notes
|
||||
|
||||
note = await Notes.get_note_by_id(entry_id, db=db)
|
||||
if note and (
|
||||
note.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=entry_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
accessible.append(entry)
|
||||
return accessible
|
||||
|
||||
|
||||
async def can_read_all_folder_files(
|
||||
entries: list[dict] | None,
|
||||
user: UserModel,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
if entries is None:
|
||||
return True
|
||||
if not isinstance(entries, list):
|
||||
return False
|
||||
if not entries:
|
||||
return True
|
||||
|
||||
return len(await get_accessible_folder_files(entries, user, db=db)) == len(entries)
|
||||
|
||||
|
||||
async def get_owner_accessible_folder_files(folder: FolderModel, db: AsyncSession | None = None) -> list[dict]:
|
||||
"""Return the folder entries its owner can still delegate."""
|
||||
files = (folder.data or {}).get('files') or []
|
||||
if not files:
|
||||
return []
|
||||
|
||||
owner = await Users.get_user_by_id(folder.user_id, db=db)
|
||||
if not owner:
|
||||
return []
|
||||
|
||||
return await get_accessible_folder_files(files, owner, db=db)
|
||||
|
||||
@@ -4,12 +4,12 @@ import sys
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.socket.main import get_event_call, get_event_emitter
|
||||
from open_webui.utils.middleware import process_tool_result
|
||||
from open_webui.utils.models import get_all_models
|
||||
from open_webui.utils.models import check_model_access, get_all_models
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
@@ -17,6 +17,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
|
||||
if not ENABLE_PLUGINS:
|
||||
raise Exception('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
|
||||
if '.' in action_id:
|
||||
action_id, sub_action_id = action_id.split('.')
|
||||
else:
|
||||
@@ -43,6 +46,23 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
|
||||
raise Exception('Model not found')
|
||||
model = models[model_id]
|
||||
|
||||
# Availability gate — keep this route consistent with the actions a model
|
||||
# actually surfaces to the client. Executing admin-authored Function code is
|
||||
# intended; this only stops a disabled, unassigned, or access-restricted
|
||||
# action from being reached by calling the route with a raw action_id.
|
||||
if action.type != 'action' or not action.is_active:
|
||||
raise Exception(f'Action not available: {action_id}')
|
||||
|
||||
# Direct connections carry a client-supplied model the caller already owns,
|
||||
# so scope the model-bound checks to server-resolved models.
|
||||
if not getattr(request.state, 'direct', False) and user.role != 'admin':
|
||||
await check_model_access(user, model)
|
||||
# model['actions'] entries are '<function_id>' or '<function_id>.<sub_id>';
|
||||
# the function id is always the prefix.
|
||||
surfaced_action_ids = {item.get('id', '').split('.', 1)[0] for item in model.get('actions', [])}
|
||||
if action_id not in surfaced_action_ids:
|
||||
raise Exception(f'Action not available: {action_id}')
|
||||
|
||||
__event_emitter__ = await get_event_emitter(
|
||||
{
|
||||
'chat_id': data['chat_id'],
|
||||
|
||||
@@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ANTHROPIC_CONVERTED_REQUEST_PARAMS = {
|
||||
'model',
|
||||
'messages',
|
||||
'system',
|
||||
'max_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'top_k',
|
||||
'stop_sequences',
|
||||
'stream',
|
||||
'metadata',
|
||||
'service_tier',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'reasoning_effort',
|
||||
}
|
||||
|
||||
|
||||
def is_anthropic_url(url: str) -> bool:
|
||||
"""Check if the URL is an Anthropic API endpoint."""
|
||||
@@ -109,7 +126,16 @@ def _finalize_openai_content(blocks: list) -> str | list:
|
||||
return blocks
|
||||
|
||||
|
||||
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
def is_anthropic_messages_passthrough(url: str, api_config: dict | None = None) -> bool:
|
||||
api_config = api_config or {}
|
||||
provider = str(api_config.get('provider', '')).lower()
|
||||
|
||||
return is_anthropic_url(url or '') or provider == 'litellm'
|
||||
|
||||
|
||||
def convert_anthropic_to_openai_payload(
|
||||
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
|
||||
|
||||
@@ -173,6 +199,8 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
},
|
||||
)
|
||||
)
|
||||
elif block_type in ('thinking', 'redacted_thinking'):
|
||||
openai_content.append(_copy_cache_control(block, dict(block)))
|
||||
elif block_type == 'image':
|
||||
source = block.get('source', {})
|
||||
if source.get('type') == 'base64':
|
||||
@@ -350,6 +378,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
if 'max_tokens' in anthropic_payload:
|
||||
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
|
||||
|
||||
captured_passthrough_params = {
|
||||
param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS
|
||||
}
|
||||
if isinstance(passthrough_params, str):
|
||||
passthrough_params = passthrough_params.split(',')
|
||||
elif not isinstance(passthrough_params, (list, tuple, set)):
|
||||
passthrough_params = []
|
||||
passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()}
|
||||
if '*' in passthrough_param_names:
|
||||
openai_payload.update(captured_passthrough_params)
|
||||
else:
|
||||
for param in passthrough_param_names:
|
||||
if param in captured_passthrough_params:
|
||||
openai_payload[param] = captured_passthrough_params[param]
|
||||
|
||||
output_config = anthropic_payload.get('output_config')
|
||||
if isinstance(output_config, dict):
|
||||
if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload:
|
||||
openai_payload['reasoning_effort'] = output_config['effort']
|
||||
|
||||
format_config = output_config.get('format')
|
||||
if isinstance(format_config, dict):
|
||||
format_type = format_config.get('type')
|
||||
if format_type == 'json_schema':
|
||||
json_schema = {
|
||||
'name': format_config.get('name', 'response_schema'),
|
||||
'schema': format_config.get('schema', {}),
|
||||
}
|
||||
if 'description' in format_config:
|
||||
json_schema['description'] = format_config['description']
|
||||
if 'strict' in format_config:
|
||||
json_schema['strict'] = format_config['strict']
|
||||
openai_payload['response_format'] = {
|
||||
'type': 'json_schema',
|
||||
'json_schema': json_schema,
|
||||
}
|
||||
elif format_type == 'json_object':
|
||||
openai_payload['response_format'] = {'type': format_type}
|
||||
|
||||
if 'reasoning_effort' in anthropic_payload:
|
||||
openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort']
|
||||
|
||||
# Common parameters
|
||||
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
|
||||
if param in anthropic_payload:
|
||||
@@ -395,7 +465,9 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
return openai_payload
|
||||
|
||||
|
||||
def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') -> dict:
|
||||
def convert_openai_to_anthropic_response(
|
||||
openai_response: dict, model: str = '', input_tokens: int | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
|
||||
"""
|
||||
@@ -419,6 +491,37 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
||||
|
||||
# Build content blocks
|
||||
content = []
|
||||
message_thinking = message.get('thinking')
|
||||
thinking_blocks = message.get('thinking_blocks') or []
|
||||
if not thinking_blocks and isinstance(message_thinking, dict):
|
||||
thinking_blocks = message_thinking.get('blocks') or []
|
||||
|
||||
has_thinking = False
|
||||
for block in thinking_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
if block.get('type') == 'redacted_thinking':
|
||||
content.append({k: v for k, v in block.items() if k in {'type', 'data'}})
|
||||
has_thinking = True
|
||||
continue
|
||||
|
||||
thinking = block.get('thinking') or block.get('content') or block.get('text')
|
||||
if not thinking:
|
||||
continue
|
||||
|
||||
thinking_block = {'type': 'thinking', 'thinking': thinking}
|
||||
if block.get('signature'):
|
||||
thinking_block['signature'] = block['signature']
|
||||
content.append(thinking_block)
|
||||
has_thinking = True
|
||||
|
||||
reasoning_content = message.get('reasoning_content') or message.get('reasoning')
|
||||
if not reasoning_content and isinstance(message_thinking, str):
|
||||
reasoning_content = message_thinking
|
||||
if reasoning_content and not has_thinking:
|
||||
content.append({'type': 'thinking', 'thinking': reasoning_content})
|
||||
|
||||
message_content = message.get('content')
|
||||
if message_content:
|
||||
content.append({'type': 'text', 'text': message_content})
|
||||
@@ -441,15 +544,37 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
||||
)
|
||||
|
||||
# Usage
|
||||
openai_usage = openai_response.get('usage', {})
|
||||
openai_usage = openai_response.get('usage') or {}
|
||||
cache_creation = openai_usage.get('cache_creation_input_tokens')
|
||||
cache_read = openai_usage.get('cache_read_input_tokens')
|
||||
prompt_details = openai_usage.get('prompt_tokens_details')
|
||||
if cache_read is None and isinstance(prompt_details, dict):
|
||||
cache_read = prompt_details.get('cached_tokens')
|
||||
|
||||
usage_input = openai_usage.get('input_tokens')
|
||||
if usage_input is None:
|
||||
prompt_tokens = openai_usage.get('prompt_tokens')
|
||||
if prompt_tokens is not None:
|
||||
usage_input = max(prompt_tokens - (cache_creation or 0) - (cache_read or 0), 0)
|
||||
|
||||
usage_output = openai_usage.get('output_tokens')
|
||||
if usage_output is None:
|
||||
usage_output = openai_usage.get('completion_tokens')
|
||||
|
||||
usage = {
|
||||
'input_tokens': openai_usage.get('prompt_tokens', 0),
|
||||
'output_tokens': openai_usage.get('completion_tokens', 0),
|
||||
'input_tokens': usage_input if usage_input is not None else (input_tokens if input_tokens is not None else 0),
|
||||
'output_tokens': usage_output if usage_output is not None else 0,
|
||||
}
|
||||
if 'cache_creation_input_tokens' in openai_usage:
|
||||
usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens']
|
||||
if 'cache_read_input_tokens' in openai_usage:
|
||||
usage['cache_read_input_tokens'] = openai_usage['cache_read_input_tokens']
|
||||
if cache_creation is not None:
|
||||
usage['cache_creation_input_tokens'] = cache_creation
|
||||
if cache_read is not None:
|
||||
usage['cache_read_input_tokens'] = cache_read
|
||||
if isinstance(openai_usage.get('output_tokens_details'), dict):
|
||||
usage['output_tokens_details'] = openai_usage['output_tokens_details']
|
||||
if isinstance(openai_usage.get('server_tool_use'), dict):
|
||||
usage['server_tool_use'] = openai_usage['server_tool_use']
|
||||
if openai_usage.get('service_tier') is not None:
|
||||
usage['service_tier'] = openai_usage['service_tier']
|
||||
|
||||
return {
|
||||
'id': openai_response.get('id', f'msg_{_uuid.uuid4().hex[:24]}'),
|
||||
@@ -463,7 +588,7 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
||||
}
|
||||
|
||||
|
||||
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ''):
|
||||
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = '', input_tokens: int | None = None):
|
||||
"""
|
||||
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
|
||||
|
||||
@@ -480,13 +605,18 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
import uuid as _uuid
|
||||
|
||||
message_id = f'msg_{_uuid.uuid4().hex[:24]}'
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
cache_creation_input_tokens = None
|
||||
cache_read_input_tokens = None
|
||||
output_tokens_details = None
|
||||
server_tool_use = None
|
||||
service_tier = None
|
||||
stop_reason = 'end_turn'
|
||||
|
||||
# Track content blocks with a running index.
|
||||
# Each text block or tool_use block gets its own index.
|
||||
current_block_index = 0
|
||||
thinking_block_open = False
|
||||
text_block_open = False
|
||||
|
||||
# Accumulated state for each tool call, keyed by tool call id.
|
||||
@@ -510,7 +640,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
'model': model,
|
||||
'stop_reason': None,
|
||||
'stop_sequence': None,
|
||||
'usage': {'input_tokens': 0, 'output_tokens': 0},
|
||||
'usage': {'input_tokens': input_tokens or 0, 'output_tokens': 0},
|
||||
},
|
||||
}
|
||||
yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode()
|
||||
@@ -537,28 +667,93 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
usage_data = data.get('usage')
|
||||
if isinstance(usage_data, dict):
|
||||
cache_creation = usage_data.get('cache_creation_input_tokens')
|
||||
cache_read = usage_data.get('cache_read_input_tokens')
|
||||
prompt_details = usage_data.get('prompt_tokens_details')
|
||||
if cache_read is None and isinstance(prompt_details, dict):
|
||||
cache_read = prompt_details.get('cached_tokens')
|
||||
|
||||
usage_input = usage_data.get('input_tokens')
|
||||
if usage_input is None:
|
||||
prompt_tokens = usage_data.get('prompt_tokens')
|
||||
if prompt_tokens is not None:
|
||||
usage_input = max(prompt_tokens - (cache_creation or 0) - (cache_read or 0), 0)
|
||||
|
||||
usage_output = usage_data.get('output_tokens')
|
||||
if usage_output is None:
|
||||
usage_output = usage_data.get('completion_tokens')
|
||||
|
||||
if usage_input is not None:
|
||||
input_tokens = usage_input
|
||||
if usage_output is not None:
|
||||
output_tokens = usage_output
|
||||
if cache_creation is not None:
|
||||
cache_creation_input_tokens = cache_creation
|
||||
if cache_read is not None:
|
||||
cache_read_input_tokens = cache_read
|
||||
if isinstance(usage_data.get('output_tokens_details'), dict):
|
||||
output_tokens_details = usage_data['output_tokens_details']
|
||||
if isinstance(usage_data.get('server_tool_use'), dict):
|
||||
server_tool_use = usage_data['server_tool_use']
|
||||
if usage_data.get('service_tier') is not None:
|
||||
service_tier = usage_data['service_tier']
|
||||
|
||||
choices = data.get('choices', [])
|
||||
if not choices:
|
||||
# Check for usage in the final chunk
|
||||
if data.get('usage'):
|
||||
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
|
||||
output_tokens = data['usage'].get('completion_tokens', output_tokens)
|
||||
continue
|
||||
|
||||
delta = choices[0].get('delta', {})
|
||||
finish_reason = choices[0].get('finish_reason')
|
||||
message = choices[0].get('message') or {}
|
||||
|
||||
# Update usage if present
|
||||
if data.get('usage'):
|
||||
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
|
||||
output_tokens = data['usage'].get('completion_tokens', output_tokens)
|
||||
reasoning_content = (
|
||||
delta.get('reasoning_content')
|
||||
or delta.get('reasoning')
|
||||
or delta.get('thinking')
|
||||
or message.get('reasoning_content')
|
||||
or message.get('reasoning')
|
||||
)
|
||||
if not reasoning_content:
|
||||
thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or []
|
||||
for block in thinking_blocks:
|
||||
if isinstance(block, dict):
|
||||
reasoning_content = block.get('thinking') or block.get('content') or block.get('text')
|
||||
if reasoning_content:
|
||||
break
|
||||
|
||||
if reasoning_content and not text_block_open and not has_tool_calls:
|
||||
if not thinking_block_open:
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
'index': current_block_index,
|
||||
'content_block': {'type': 'thinking', 'thinking': ''},
|
||||
}
|
||||
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
|
||||
thinking_block_open = True
|
||||
|
||||
block_delta = {
|
||||
'type': 'content_block_delta',
|
||||
'index': current_block_index,
|
||||
'delta': {'type': 'thinking_delta', 'thinking': reasoning_content},
|
||||
}
|
||||
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
|
||||
|
||||
# --- Handle text content ---
|
||||
# Anthropic expects text blocks before tool blocks, so skip
|
||||
# text deltas once any tool call has started.
|
||||
content = delta.get('content')
|
||||
if content and not has_tool_calls:
|
||||
if thinking_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
'index': current_block_index,
|
||||
}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
thinking_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
if not text_block_open:
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
@@ -584,6 +779,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
|
||||
if tool_calls:
|
||||
# Close text block if one is open (text comes before tools)
|
||||
if thinking_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
'index': current_block_index,
|
||||
}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
thinking_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
if text_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
@@ -695,6 +899,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
except Exception as e:
|
||||
log.error(f'Error in Anthropic stream conversion: {e}')
|
||||
|
||||
# Close any open thinking block
|
||||
if thinking_block_open:
|
||||
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
current_block_index += 1
|
||||
|
||||
# Flush any tools that buffered arguments but never emitted a block
|
||||
for tool in tracked_tool_calls.values():
|
||||
if not tool['started'] and tool['name']:
|
||||
@@ -737,13 +947,27 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
|
||||
# Emit message_delta with stop reason
|
||||
usage = {'output_tokens': output_tokens}
|
||||
if input_tokens is not None:
|
||||
usage['input_tokens'] = input_tokens
|
||||
if cache_creation_input_tokens is not None:
|
||||
usage['cache_creation_input_tokens'] = cache_creation_input_tokens
|
||||
if cache_read_input_tokens is not None:
|
||||
usage['cache_read_input_tokens'] = cache_read_input_tokens
|
||||
if output_tokens_details is not None:
|
||||
usage['output_tokens_details'] = output_tokens_details
|
||||
if server_tool_use is not None:
|
||||
usage['server_tool_use'] = server_tool_use
|
||||
if service_tier is not None:
|
||||
usage['service_tier'] = service_tier
|
||||
|
||||
message_delta = {
|
||||
'type': 'message_delta',
|
||||
'delta': {
|
||||
'stop_reason': stop_reason,
|
||||
'stop_sequence': None,
|
||||
},
|
||||
'usage': {'output_tokens': output_tokens},
|
||||
'usage': usage,
|
||||
}
|
||||
yield f'event: message_delta\ndata: {json.dumps(message_delta)}\n\n'.encode()
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from open_webui.env import CUSTOM_API_KEY_HEADER
|
||||
from open_webui.internal.db import ScopedSession
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.auth import get_http_authorization_cred
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request
|
||||
@@ -101,14 +100,20 @@ class CommitSessionMiddleware:
|
||||
# Downstream did not complete successfully. Roll back any
|
||||
# pending sync writes, release the connection, and let the
|
||||
# exception propagate.
|
||||
try:
|
||||
ScopedSession.rollback()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
|
||||
finally:
|
||||
ScopedSession.remove()
|
||||
if ScopedSession.registry.has():
|
||||
try:
|
||||
ScopedSession.rollback()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
|
||||
finally:
|
||||
ScopedSession.remove()
|
||||
raise
|
||||
|
||||
# Nothing in this request touched the sync session: committing would
|
||||
# only instantiate one to run an empty transaction.
|
||||
if not ScopedSession.registry.has():
|
||||
return
|
||||
|
||||
# Downstream completed. Commit pending sync work.
|
||||
try:
|
||||
ScopedSession.commit()
|
||||
@@ -138,9 +143,7 @@ class AuthTokenMiddleware:
|
||||
the middleware checks that instead and avoids the 401 short-circuit.
|
||||
|
||||
Routes that depend on `get_verified_user` etc. read this state.
|
||||
Also exposes `request.state.enable_api_keys` (snapshotted at request
|
||||
entry from runtime config) and stamps an `X-Process-Time` response
|
||||
header.
|
||||
Also stamps an `X-Process-Time` response header.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, fastapi_app) -> None:
|
||||
@@ -166,13 +169,12 @@ class AuthTokenMiddleware:
|
||||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
|
||||
|
||||
request.state.token = token
|
||||
request.state.enable_api_keys = await Config.get('auth.enable_api_keys')
|
||||
|
||||
async def send_with_timing(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
process_time = int(time.monotonic() - start_time)
|
||||
process_time = time.monotonic() - start_time
|
||||
headers = MutableHeaders(scope=message)
|
||||
headers['X-Process-Time'] = str(process_time)
|
||||
headers['X-Process-Time'] = f'{process_time:.6f}'
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_timing)
|
||||
@@ -231,7 +233,15 @@ class RedirectMiddleware:
|
||||
return
|
||||
|
||||
path = scope.get('path', '')
|
||||
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
|
||||
raw_query = scope.get('query_string', b'')
|
||||
# This middleware only acts on /watch?v= and ?shared= URLs; skip the
|
||||
# decode + parse_qs work for every other GET. (A false positive on the
|
||||
# substring check just falls through to the full parse below.)
|
||||
if not (path.endswith('/watch') or b'shared' in raw_query):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
query_string = raw_query.decode('latin-1', errors='replace')
|
||||
query_params = parse_qs(query_string)
|
||||
|
||||
redirect_params: dict[str, str] = {}
|
||||
|
||||
@@ -132,14 +132,27 @@ class AuditLoggingMiddleware:
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.audit_logger = AuditLogger(logger)
|
||||
self.excluded_paths = excluded_paths or []
|
||||
self.included_paths = included_paths or []
|
||||
|
||||
def normalize_paths(paths: Optional[list[str]]) -> list[str]:
|
||||
return [path for path in (path.strip().lstrip('/') for path in paths or []) if path]
|
||||
|
||||
self.excluded_paths = normalize_paths(excluded_paths)
|
||||
self.included_paths = normalize_paths(included_paths)
|
||||
self.max_body_size = max_body_size
|
||||
self.audited_methods = set(self.DEFAULT_AUDITED_METHODS)
|
||||
if audit_get_requests:
|
||||
self.audited_methods.add('GET')
|
||||
self.audit_level = audit_level
|
||||
|
||||
# Paths are fixed for the process lifetime; compile once instead of
|
||||
# per request. None means the corresponding mode has nothing to match.
|
||||
self._included_pattern = (
|
||||
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') if self.included_paths else None
|
||||
)
|
||||
self._excluded_pattern = (
|
||||
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') if self.excluded_paths else None
|
||||
)
|
||||
|
||||
if self.included_paths and self.excluded_paths:
|
||||
logger.warning(
|
||||
'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. '
|
||||
@@ -196,6 +209,13 @@ class AuditLoggingMiddleware:
|
||||
await self._log_audit_entry(request, context)
|
||||
|
||||
async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
|
||||
# get_current_user stashes the resolved user on the scope-backed state;
|
||||
# reuse it instead of running the full auth pipeline (JWT decode, Redis
|
||||
# revocation checks, DB fetch, last-active write) a second time.
|
||||
user = getattr(request.state, 'user', None)
|
||||
if isinstance(user, UserModel):
|
||||
return user
|
||||
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
try:
|
||||
@@ -206,6 +226,12 @@ class AuditLoggingMiddleware:
|
||||
|
||||
return None
|
||||
|
||||
ALWAYS_LOG_ENDPOINTS = (
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signout',
|
||||
'/api/v1/auths/signup',
|
||||
)
|
||||
|
||||
def _should_skip_auditing(self, request: Request) -> bool:
|
||||
if AUDIT_LOG_LEVEL == 'NONE':
|
||||
return True
|
||||
@@ -213,13 +239,8 @@ class AuditLoggingMiddleware:
|
||||
if request.method not in self.audited_methods:
|
||||
return True
|
||||
|
||||
ALWAYS_LOG_ENDPOINTS = {
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signout',
|
||||
'/api/v1/auths/signup',
|
||||
}
|
||||
path = request.url.path.lower()
|
||||
for endpoint in ALWAYS_LOG_ENDPOINTS:
|
||||
for endpoint in self.ALWAYS_LOG_ENDPOINTS:
|
||||
if path.startswith(endpoint):
|
||||
return False # Do NOT skip logging for auth endpoints
|
||||
|
||||
@@ -229,15 +250,11 @@ class AuditLoggingMiddleware:
|
||||
return True
|
||||
|
||||
# Whitelist mode: only log paths that match included_paths
|
||||
if self.included_paths:
|
||||
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b')
|
||||
if not pattern.match(request.url.path):
|
||||
return True # Skip: path not in whitelist
|
||||
return False # Do NOT skip: path is in whitelist
|
||||
if self._included_pattern:
|
||||
return not self._included_pattern.match(request.url.path)
|
||||
|
||||
# Blacklist mode: skip paths that match excluded_paths
|
||||
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b')
|
||||
if pattern.match(request.url.path):
|
||||
if self._excluded_pattern and self._excluded_pattern.match(request.url.path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -96,11 +96,15 @@ def get_license_data(app, key):
|
||||
setattr(app.state, 'LICENSE_METADATA', v)
|
||||
|
||||
def handler(u):
|
||||
res = requests.post(
|
||||
f'{u}/api/v1/license/',
|
||||
json={'key': key, 'version': '1'},
|
||||
timeout=5,
|
||||
)
|
||||
try:
|
||||
res = requests.post(
|
||||
f'{u}/api/v1/license/',
|
||||
json={'key': key, 'version': '1'},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as ex:
|
||||
log.error(f'License: retrieval issue from {u}: {ex}')
|
||||
return False
|
||||
|
||||
if getattr(res, 'ok', False):
|
||||
payload = getattr(res, 'json', lambda: {})()
|
||||
@@ -352,6 +356,8 @@ async def get_current_user(
|
||||
current_span.set_attribute('client.user.role', user.role)
|
||||
current_span.set_attribute('client.auth.type', 'api_key')
|
||||
|
||||
# Scope-backed, so outer middleware (audit) can reuse the resolved user
|
||||
request.state.user = user
|
||||
return user
|
||||
|
||||
# auth by jwt token
|
||||
@@ -399,9 +405,10 @@ async def get_current_user(
|
||||
|
||||
# Refresh the user's last active timestamp
|
||||
# Fire-and-forget via asyncio.create_task to avoid blocking
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(Users.update_last_active_by_id(user.id))
|
||||
|
||||
# Scope-backed, so outer middleware (audit) can reuse the resolved user
|
||||
request.state.user = user
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -433,24 +440,30 @@ async def get_current_user_by_api_key(request, api_key: str):
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
user_permissions = await Config.get('user.permissions')
|
||||
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
|
||||
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
|
||||
config_values = await Config.get_many(
|
||||
'auth.enable_api_keys',
|
||||
'user.permissions',
|
||||
'auth.api_key.endpoint_restrictions',
|
||||
'auth.api_key.allowed_endpoints',
|
||||
)
|
||||
|
||||
if not request.state.enable_api_keys or (
|
||||
user.role != 'admin'
|
||||
and not await has_permission(
|
||||
if not config_values.get('auth.enable_api_keys'):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
|
||||
if user.role != 'admin':
|
||||
user_permissions = config_values.get('user.permissions')
|
||||
if not await has_permission(
|
||||
user.id,
|
||||
'features.api_keys',
|
||||
user_permissions,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
|
||||
# Enforce endpoint restrictions — checked here (not in middleware)
|
||||
# so it applies regardless of how the API key was transported
|
||||
# (Authorization header, cookie, x-api-key header, etc.).
|
||||
if enable_endpoint_restrictions:
|
||||
if config_values.get('auth.api_key.endpoint_restrictions'):
|
||||
allowed_endpoints = config_values.get('auth.api_key.allowed_endpoints', '')
|
||||
allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()]
|
||||
request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
|
||||
is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths)
|
||||
@@ -475,8 +488,11 @@ async def get_current_user_by_api_key(request, api_key: str):
|
||||
return user
|
||||
|
||||
|
||||
VERIFIED_USER_ROLES = {'user', 'admin'}
|
||||
|
||||
|
||||
def get_verified_user(user=Depends(get_current_user)):
|
||||
if user.role not in {'user', 'admin'}:
|
||||
if user.role not in VERIFIED_USER_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
@@ -484,6 +500,19 @@ def get_verified_user(user=Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
|
||||
async def get_verified_user_by_token(token: str, redis=None):
|
||||
"""Resolve a verified user from a raw token, for WebSocket handshakes that run outside the HTTP dependency chain."""
|
||||
decoded = decode_token(token)
|
||||
if decoded is None or 'id' not in decoded or not await is_valid_token(decoded, redis):
|
||||
return None
|
||||
|
||||
user = await Users.get_user_by_id(decoded['id'])
|
||||
if user is None or user.role not in VERIFIED_USER_ROLES:
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_admin_user(user=Depends(get_current_user)):
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(
|
||||
|
||||
@@ -7,9 +7,11 @@ Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
|
||||
The scheduler_worker_loop handles all time-based background work:
|
||||
- Automation execution (claim_due → execute)
|
||||
- Calendar event alerts (upcoming events → socket + webhook notifications)
|
||||
- One-shot chat timers
|
||||
|
||||
Environment:
|
||||
SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10)
|
||||
TIMER_POLL_INTERVAL – seconds between timer polls (default: 1)
|
||||
CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5)
|
||||
"""
|
||||
|
||||
@@ -23,6 +25,7 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil import parser as date_parser
|
||||
from dateutil.rrule import rrulestr
|
||||
from fastapi import Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
@@ -32,15 +35,18 @@ from open_webui.internal.db import get_async_db
|
||||
from open_webui.models.automations import AutomationModel, AutomationRuns, Automations
|
||||
from open_webui.models.chats import ChatForm, Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.auth import create_token
|
||||
from open_webui.utils.misc import parse_duration
|
||||
from open_webui.utils.task import prompt_template
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10')))
|
||||
TIMER_POLL_INTERVAL = int(os.getenv('TIMER_POLL_INTERVAL', '1'))
|
||||
CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10'))
|
||||
|
||||
|
||||
@@ -65,19 +71,49 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_rule(s: str):
|
||||
def _parse_rule(s: str, now: Optional[datetime] = None):
|
||||
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
|
||||
|
||||
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
|
||||
SECONDLY/MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
|
||||
so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10).
|
||||
"""
|
||||
raw = s.replace('RRULE:', '')
|
||||
parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p)
|
||||
lines = s.splitlines()
|
||||
rule_count = sum(1 for line in lines if line.upper().startswith('RRULE:'))
|
||||
if 'EXRULE' in s.upper():
|
||||
raise ValueError('EXRULE is not supported in recurrence rules')
|
||||
if rule_count > 1:
|
||||
raise ValueError('only one RRULE is supported per recurrence rule')
|
||||
|
||||
rrule_line = next((line for line in lines if line.upper().startswith('RRULE:')), s)
|
||||
raw = rrule_line.split(':', 1)[1] if rrule_line.upper().startswith('RRULE:') else rrule_line
|
||||
parts = {k.upper(): v for k, v in (p.split('=', 1) for p in raw.split(';') if '=' in p)}
|
||||
freq = parts.get('FREQ', '')
|
||||
|
||||
if freq in ('MINUTELY', 'HOURLY'):
|
||||
if freq in ('SECONDLY', 'MINUTELY', 'HOURLY'):
|
||||
epoch = datetime(2000, 1, 1, 0, 0, 0)
|
||||
return rrulestr(s, dtstart=epoch, ignoretz=True)
|
||||
anchor = now or datetime.now()
|
||||
rule = '\n'.join(line for line in lines if not line.upper().startswith('DTSTART')) or s
|
||||
dtstart = next((line.rsplit(':', 1)[-1] for line in lines if line.upper().startswith('DTSTART')), None)
|
||||
interval = int(parts.get('INTERVAL', '1'))
|
||||
if interval < 1:
|
||||
raise ValueError('RRULE INTERVAL must be a positive integer')
|
||||
if freq == 'SECONDLY':
|
||||
step = timedelta(seconds=interval)
|
||||
elif freq == 'MINUTELY':
|
||||
step = timedelta(minutes=interval)
|
||||
else:
|
||||
step = timedelta(hours=interval)
|
||||
if dtstart:
|
||||
start = date_parser.parse(dtstart, ignoretz=True)
|
||||
emitted = ((anchor - start) // step) if anchor > start else 0
|
||||
if 'BYMINUTE' in parts:
|
||||
emitted *= len(parts['BYMINUTE'].split(','))
|
||||
if 'BYSECOND' in parts:
|
||||
emitted *= len(parts['BYSECOND'].split(','))
|
||||
if emitted <= 100_000:
|
||||
return rrulestr(s, ignoretz=True)
|
||||
anchor = epoch + ((anchor - epoch) // step) * step
|
||||
return rrulestr(rule, dtstart=anchor, ignoretz=True)
|
||||
return rrulestr(s, ignoretz=True)
|
||||
|
||||
|
||||
@@ -88,12 +124,12 @@ def validate_rrule(s: str, tz: str = None) -> None:
|
||||
clock so that near-future schedules are not incorrectly rejected
|
||||
on servers whose system clock is ahead (e.g. UTC vs US timezones).
|
||||
"""
|
||||
try:
|
||||
rule = _parse_rule(s)
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
zi = _resolve_tz(tz)
|
||||
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
||||
try:
|
||||
rule = _parse_rule(s, now)
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
if rule.after(now) is None:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
|
||||
|
||||
@@ -102,7 +138,8 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]:
|
||||
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
|
||||
zi = _resolve_tz(tz)
|
||||
now = datetime.now(zi) if zi else datetime.now()
|
||||
dt = _parse_rule(s).after(now.replace(tzinfo=None))
|
||||
now_naive = now.replace(tzinfo=None)
|
||||
dt = _parse_rule(s, now_naive).after(now_naive)
|
||||
if dt is None:
|
||||
return None
|
||||
if zi:
|
||||
@@ -117,9 +154,9 @@ def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
|
||||
preview matches the user's local clock (same as next_run_ns).
|
||||
"""
|
||||
zi = _resolve_tz(tz)
|
||||
rule = _parse_rule(s)
|
||||
result = []
|
||||
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
||||
rule = _parse_rule(s, now)
|
||||
dt = now
|
||||
for _ in range(n):
|
||||
dt = rule.after(dt)
|
||||
@@ -141,8 +178,8 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
|
||||
"""
|
||||
if 'COUNT=1' in s:
|
||||
return None
|
||||
rule = _parse_rule(s)
|
||||
now = datetime.now()
|
||||
rule = _parse_rule(s, now)
|
||||
first = rule.after(now)
|
||||
if first is None:
|
||||
return None
|
||||
@@ -173,9 +210,30 @@ async def scheduler_worker_loop(app) -> None:
|
||||
Runs on every instance. Poll interval is configurable via
|
||||
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
|
||||
"""
|
||||
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
|
||||
log.info(
|
||||
f'Scheduler worker started (timer poll interval: {TIMER_POLL_INTERVAL}s, '
|
||||
f'scheduler poll interval: {SCHEDULER_POLL_INTERVAL}s)'
|
||||
)
|
||||
next_scheduler_poll = 0.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = time.monotonic()
|
||||
# ── Timers ──
|
||||
try:
|
||||
from open_webui.utils.timers import claim_due_timers, execute_due_timer
|
||||
|
||||
for timer_id, claim_id in await claim_due_timers(int(time.time_ns()), limit=10):
|
||||
asyncio.create_task(execute_due_timer(app, timer_id, claim_id))
|
||||
except Exception:
|
||||
log.exception('Scheduler: timer error')
|
||||
|
||||
if now < next_scheduler_poll:
|
||||
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
|
||||
continue
|
||||
# Jitter to spread automation/calendar load across instances; timers keep a tight poll.
|
||||
next_scheduler_poll = now + SCHEDULER_POLL_INTERVAL + random.uniform(0, 2)
|
||||
|
||||
# ── Automations ──
|
||||
if await Config.get('automations.enable'):
|
||||
try:
|
||||
@@ -198,8 +256,7 @@ async def scheduler_worker_loop(app) -> None:
|
||||
except Exception:
|
||||
log.exception('Scheduler worker error')
|
||||
|
||||
# Jitter to spread load across instances
|
||||
await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2))
|
||||
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
|
||||
|
||||
|
||||
##########################
|
||||
@@ -323,16 +380,11 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
|
||||
log.warning(f'Terminal server {server_id} not found for CWD set')
|
||||
return
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
return
|
||||
|
||||
# Build target URL — route through orchestrator policy if configured
|
||||
policy_id = connection.get('policy_id')
|
||||
if connection.get('server_type') == 'orchestrator' and policy_id:
|
||||
target_url = f'{base_url}/p/{policy_id}/files/cwd'
|
||||
else:
|
||||
target_url = f'{base_url}/files/cwd'
|
||||
target_url = f'{base_url}/files/cwd'
|
||||
|
||||
headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
|
||||
if chat_id:
|
||||
@@ -396,7 +448,10 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
|
||||
prompt = await prompt_template(automation.data['prompt'], user)
|
||||
model_id = automation.data['model_id']
|
||||
terminal_config = automation.data.get('terminal')
|
||||
folder_id = automation.folder_id
|
||||
if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id):
|
||||
await Automations.clear_folder_ids(automation.user_id, [folder_id])
|
||||
folder_id = None
|
||||
|
||||
# Generate proper UUIDs for messages (same as frontend)
|
||||
user_msg_id = str(uuid4())
|
||||
@@ -407,6 +462,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
chat_id,
|
||||
automation.user_id,
|
||||
ChatForm(
|
||||
folder_id=folder_id,
|
||||
chat={
|
||||
'title': automation.name,
|
||||
'models': [model_id],
|
||||
@@ -438,7 +494,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
'meta': {'automation_id': automation.id},
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -613,40 +669,25 @@ async def _check_calendar_alerts(app) -> None:
|
||||
except Exception:
|
||||
log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True)
|
||||
|
||||
# Send webhook notification if user has one configured
|
||||
# Send target notification if user has one configured
|
||||
try:
|
||||
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
|
||||
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
||||
|
||||
if enable_user_webhooks:
|
||||
user = await Users.get_user_by_id(event.user_id)
|
||||
if user and user.settings:
|
||||
webhook_url = (
|
||||
user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
|
||||
if isinstance(user.settings, dict)
|
||||
else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)(
|
||||
'notifications', {}
|
||||
).get('webhook_url', None)
|
||||
if hasattr(user.settings, 'ui')
|
||||
else None
|
||||
)
|
||||
if webhook_url:
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
|
||||
await post_webhook(
|
||||
webui_name,
|
||||
webhook_url,
|
||||
f'{event.title} — starting {time_str}',
|
||||
{
|
||||
'action': 'calendar_alert',
|
||||
'title': event.title,
|
||||
'minutes_until': minutes_until,
|
||||
'event_id': event.id,
|
||||
},
|
||||
)
|
||||
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
|
||||
await publish_event(
|
||||
app,
|
||||
EVENTS.CALENDAR_ALERT,
|
||||
subject_id=event.id,
|
||||
subject_type='calendar.event',
|
||||
source='scheduler',
|
||||
data={
|
||||
**alert_data,
|
||||
'user_id': event.user_id,
|
||||
'starts_in': time_str,
|
||||
'message': f'{event.title}: starting {time_str}',
|
||||
},
|
||||
message=event.title,
|
||||
)
|
||||
except Exception:
|
||||
log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True)
|
||||
log.debug(f'Failed to send notification for calendar alert {event.id}', exc_info=True)
|
||||
|
||||
|
||||
async def _record_run(
|
||||
|
||||
@@ -11,7 +11,6 @@ from aiocache import cached
|
||||
from fastapi import HTTPException, Request, status
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.functions import generate_function_chat_completion
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.routers.ollama import (
|
||||
@@ -30,7 +29,7 @@ from open_webui.socket.main import (
|
||||
sio,
|
||||
)
|
||||
from open_webui.utils.filter import (
|
||||
get_sorted_filter_ids,
|
||||
get_filter_functions,
|
||||
process_filter_functions,
|
||||
)
|
||||
from open_webui.utils.models import check_model_access, get_all_models
|
||||
@@ -179,8 +178,10 @@ async def generate_chat_completion(
|
||||
# Merge the direct connection model into server models so that
|
||||
# task functions (title, tags, etc.) can resolve a server-side
|
||||
# task model while still having the direct model available.
|
||||
# dict(...items()) is one HGETALL on a Redis-backed pool; ``{**pool}``
|
||||
# would issue HKEYS plus one HGET per model.
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
**dict(request.app.state.MODELS.items()),
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
log.debug(f'direct connection to model: {request.state.model["id"]}')
|
||||
@@ -188,11 +189,12 @@ async def generate_chat_completion(
|
||||
models = request.app.state.MODELS
|
||||
|
||||
model_id = form_data['model']
|
||||
if model_id not in models:
|
||||
# Single lookup — membership check plus getitem would be two Redis
|
||||
# round trips on a Redis-backed model pool.
|
||||
model = models.get(model_id)
|
||||
if model is None:
|
||||
raise Exception('Model not found')
|
||||
|
||||
model = models[model_id]
|
||||
|
||||
if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'):
|
||||
return await generate_direct_chat_completion(request, form_data, user=user, models=models)
|
||||
else:
|
||||
@@ -359,11 +361,11 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
||||
}
|
||||
|
||||
try:
|
||||
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
|
||||
filter_functions = await Functions.get_functions_by_ids(filter_ids)
|
||||
filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', []))
|
||||
|
||||
result, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_context=None,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='outlet',
|
||||
form_data=data,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
def build_fork_history(messages_map: dict, source_message_id: str) -> tuple[dict, list[dict]]:
|
||||
if not messages_map:
|
||||
raise ValueError('chat has no messages to fork')
|
||||
|
||||
branch: list[tuple[str, dict]] = []
|
||||
seen: set[str] = set()
|
||||
message_id = source_message_id
|
||||
|
||||
while message_id:
|
||||
if message_id in seen:
|
||||
raise ValueError('message branch contains a cycle')
|
||||
seen.add(message_id)
|
||||
|
||||
message = messages_map.get(message_id)
|
||||
if not isinstance(message, dict):
|
||||
raise ValueError('message not found')
|
||||
|
||||
branch.append((message_id, message))
|
||||
message_id = message.get('parentId')
|
||||
|
||||
fork_messages: dict[str, dict] = {}
|
||||
ordered_messages: list[dict] = []
|
||||
parent_id = None
|
||||
|
||||
for message_id, message in reversed(branch):
|
||||
copied = deepcopy(message)
|
||||
copied['id'] = message_id
|
||||
copied['parentId'] = parent_id
|
||||
copied['childrenIds'] = []
|
||||
|
||||
if parent_id:
|
||||
fork_messages[parent_id]['childrenIds'] = [message_id]
|
||||
|
||||
fork_messages[message_id] = copied
|
||||
ordered_messages.append(copied)
|
||||
parent_id = message_id
|
||||
|
||||
return {'messages': fork_messages, 'currentId': source_message_id}, ordered_messages
|
||||
@@ -0,0 +1,27 @@
|
||||
from typing import Optional
|
||||
|
||||
|
||||
TEMPORARY_CHAT_ID_PREFIX = 'temporary:'
|
||||
LEGACY_TEMPORARY_CHAT_ID_PREFIX = 'local:' # Legacy temporary chat prefix.
|
||||
CHANNEL_CHAT_ID_PREFIX = 'channel:'
|
||||
|
||||
TEMPORARY_CHAT_ID_PREFIXES = (
|
||||
TEMPORARY_CHAT_ID_PREFIX,
|
||||
LEGACY_TEMPORARY_CHAT_ID_PREFIX,
|
||||
)
|
||||
NON_SAVED_CHAT_ID_PREFIXES = (*TEMPORARY_CHAT_ID_PREFIXES, CHANNEL_CHAT_ID_PREFIX)
|
||||
|
||||
|
||||
def is_saved_chat_id(chat_id: Optional[str]) -> bool:
|
||||
return bool(chat_id) and not chat_id.startswith(NON_SAVED_CHAT_ID_PREFIXES)
|
||||
|
||||
|
||||
def is_temporary_chat_id(chat_id: Optional[str]) -> bool:
|
||||
return bool(chat_id) and chat_id.startswith(TEMPORARY_CHAT_ID_PREFIXES)
|
||||
|
||||
|
||||
def get_temporary_chat_session_id(chat_id: str) -> Optional[str]:
|
||||
for prefix in TEMPORARY_CHAT_ID_PREFIXES:
|
||||
if chat_id.startswith(prefix):
|
||||
return chat_id.removeprefix(prefix)
|
||||
return None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user