Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8112d72b9 | ||
|
|
71ccedd2bf | ||
|
|
4225791313 | ||
|
|
e5cd1b479b | ||
|
|
61d44aa773 | ||
|
|
ef036529b5 | ||
|
|
e0bdef85ab | ||
|
|
2ce935bdb1 | ||
|
|
23b1e2cca4 | ||
|
|
05b8768fb9 | ||
|
|
173d5631ca | ||
|
|
34cd3d79e8 | ||
|
|
aede1b7a08 | ||
|
|
15b893e651 | ||
|
|
4896d30281 | ||
|
|
9be45f49e4 | ||
|
|
f1053d94c7 | ||
|
|
10cfddccd7 | ||
|
|
e5e39be90f | ||
|
|
337109e99c | ||
|
|
8adf2b33b4 | ||
|
|
1984ce42aa | ||
|
|
656de56a3e | ||
|
|
0b2abe6cb8 | ||
|
|
f364b2d205 | ||
|
|
519ff40cb6 | ||
|
|
7c7fe44328 | ||
|
|
15b5f97f89 | ||
|
|
ca0983f76b | ||
|
|
ef04a704ce | ||
|
|
5fda814669 | ||
|
|
c26e8110af | ||
|
|
9e85055b8b | ||
|
|
6d17de6c67 | ||
|
|
f4e99c80f6 | ||
|
|
09dc28df1e | ||
|
|
c748c3ede7 | ||
|
|
33308022f0 | ||
|
|
f96e8f04fc | ||
|
|
38ae91ae23 | ||
|
|
88401e91c7 | ||
|
|
8c5cfa530d | ||
|
|
d215e46315 | ||
|
|
e10e7d056e | ||
|
|
7a7d902238 | ||
|
|
24179cde2f | ||
|
|
3ae4c618e1 | ||
|
|
4a0d893995 | ||
|
|
b780d5c556 | ||
|
|
911eecac85 | ||
|
|
319d3e8856 | ||
|
|
e1b3e7252c | ||
|
|
f20cc6d7e6 | ||
|
|
f1a1e64d2e | ||
|
|
58e923fe00 | ||
|
|
f2aca781c8 | ||
|
|
ce51c481b8 | ||
|
|
9a2595f070 | ||
|
|
393c0071dc | ||
|
|
7e224e4a53 | ||
|
|
883f1dda0f | ||
|
|
12bad452fa | ||
|
|
5de60dc922 | ||
|
|
a30b106ea3 | ||
|
|
d33ad462aa | ||
|
|
3b61562c82 | ||
|
|
e5d88be4f3 | ||
|
|
b36f8d9314 | ||
|
|
626d236d13 | ||
|
|
79ecbfc757 | ||
|
|
a9b8677cc0 | ||
|
|
0f3f68b0c4 | ||
|
|
abc9b63093 | ||
|
|
64fa26bd28 | ||
|
|
2487c84f1f | ||
|
|
163211a367 | ||
|
|
f027a01ab2 | ||
|
|
370a677a38 | ||
|
|
d1d1efe212 | ||
|
|
b7549d2f6c | ||
|
|
589c4e64c1 | ||
|
|
20de5a87da | ||
|
|
ca6b18ab5c | ||
|
|
97a3b1528d | ||
|
|
d01b1d4880 | ||
|
|
df6e38039f | ||
|
|
b4c3f54f96 | ||
|
|
73776d54b8 | ||
|
|
7bda6bf767 | ||
|
|
ddcec9842f | ||
|
|
4d5b7b3014 | ||
|
|
9886ebb97f | ||
|
|
0b05b2fc7e | ||
|
|
49e7eade15 | ||
|
|
7a7a25766c | ||
|
|
9fc1658085 | ||
|
|
5297dceb2a | ||
|
|
59afbd6f92 | ||
|
|
f7af3f010e | ||
|
|
bb40724d45 | ||
|
|
0e64b31adb | ||
|
|
850a864b02 | ||
|
|
9468d92553 | ||
|
|
c3dc5d5984 | ||
|
|
5291b3dca2 | ||
|
|
87d0c112fa | ||
|
|
2a11175f22 | ||
|
|
3238d94a0e | ||
|
|
8919d8a82a | ||
|
|
ea4ef28da5 | ||
|
|
2ffd8d9277 | ||
|
|
e8499ccdd1 | ||
|
|
0dcbd05e24 | ||
|
|
c653e4ec54 | ||
|
|
8cf32ae2a7 | ||
|
|
c6af296b60 | ||
|
|
531ac70ce5 | ||
|
|
423d8b1817 | ||
|
|
633505460a | ||
|
|
c8bf390680 | ||
|
|
da46c1bbd2 | ||
|
|
05ae44b98d | ||
|
|
a40808579f | ||
|
|
4d024c91d6 | ||
|
|
ccb71a7322 | ||
|
|
efe5416f83 | ||
|
|
a4281f6a7f | ||
|
|
2372b70031 | ||
|
|
d02e826c9d | ||
|
|
dddac2b0ca | ||
|
|
0da57149ae | ||
|
|
0bebb260bf | ||
|
|
390d7663b0 | ||
|
|
ce3a615442 | ||
|
|
4e0cb88583 | ||
|
|
9b30e8f689 | ||
|
|
97331bf11d | ||
|
|
96c07f44a8 | ||
|
|
60ada21c15 | ||
|
|
ab20745ee5 | ||
|
|
f376d4f378 | ||
|
|
89fddcc741 | ||
|
|
773787c74c | ||
|
|
5c1c9a4dcb | ||
|
|
9b925a115a | ||
|
|
e5035ea31e | ||
|
|
c8cbdc8f7f | ||
|
|
64c37ab968 | ||
|
|
f7c5965a70 | ||
|
|
46aa54b7dc | ||
|
|
27944cf7ca | ||
|
|
1973115678 | ||
|
|
a38ad8fc42 | ||
|
|
c2207887b3 | ||
|
|
3fabc085cc | ||
|
|
2f584c9f88 | ||
|
|
ba18d6250a | ||
|
|
4331029926 | ||
|
|
3e56261c5e | ||
|
|
30f72672fa | ||
|
|
4aedfdc547 | ||
|
|
e3a8257690 | ||
|
|
a73cdf4288 | ||
|
|
c259c87806 | ||
|
|
0044902c08 | ||
|
|
cd31b8301b | ||
|
|
8fd5c06e5b | ||
|
|
97afe3bc58 | ||
|
|
3567054325 | ||
|
|
f236192fe1 | ||
|
|
9b1fd86aa7 | ||
|
|
55169e69c0 | ||
|
|
e3e4e1d9d3 | ||
|
|
c2f5cb542e | ||
|
|
1034b74abd | ||
|
|
0a44d80252 | ||
|
|
48a0abb40f | ||
|
|
68c77295bd | ||
|
|
3c7f9aa6a4 | ||
|
|
f7406ff576 | ||
|
|
65904b867e | ||
|
|
e2d09ac361 | ||
|
|
3ae44d11a5 | ||
|
|
aa8c2959ca | ||
|
|
b147616080 | ||
|
|
9747b07ca5 | ||
|
|
0f78451c2b | ||
|
|
42763cbbd8 | ||
|
|
d193c143a5 | ||
|
|
26460917c4 | ||
|
|
fd97ae9bd7 | ||
|
|
c3fa0f30fe | ||
|
|
4852227158 | ||
|
|
9be85b6d3c | ||
|
|
59b98ab730 | ||
|
|
690686f3c7 | ||
|
|
691a04f0dd | ||
|
|
4fa6123c04 | ||
|
|
494cf8b3ef | ||
|
|
73bb600034 | ||
|
|
9cf4d34832 | ||
|
|
284b97bd84 | ||
|
|
7e79f8d1c6 | ||
|
|
258454276e | ||
|
|
938d1b0743 | ||
|
|
b1737040a7 | ||
|
|
26286625f4 | ||
|
|
a214ec40ea | ||
|
|
2c37daef86 | ||
|
|
8e79b3d0bc | ||
|
|
6c0f886cdf | ||
|
|
225036863f | ||
|
|
cac5dd12e9 | ||
|
|
f751c0b46c | ||
|
|
9ed8f50d40 | ||
|
|
d3f2cf7474 | ||
|
|
62750b8980 | ||
|
|
e62649f940 | ||
|
|
0e60c757ce | ||
|
|
68a1e87b66 | ||
|
|
e8a36f033b | ||
|
|
2cf2565e80 | ||
|
|
5669d1062c | ||
|
|
3ace75820e | ||
|
|
020cb0d4bf | ||
|
|
8b75d34a8a | ||
|
|
6320a9aaa9 | ||
|
|
6cd35b185d | ||
|
|
a1ea854b38 | ||
|
|
405dc26cc6 | ||
|
|
fe681abd33 | ||
|
|
43c68468f7 | ||
|
|
ecf3fa2feb | ||
|
|
4aacaeb9b8 | ||
|
|
afc56b9746 | ||
|
|
0a61666197 | ||
|
|
a9e0462e57 | ||
|
|
a2b9986a75 | ||
|
|
654172d757 | ||
|
|
527d48efa9 | ||
|
|
cda08aaed4 | ||
|
|
cfd30581d5 | ||
|
|
3c0313f41b | ||
|
|
d938eb0e76 | ||
|
|
60f2f8c1c4 | ||
|
|
b0c5f7b668 | ||
|
|
767343dc5b | ||
|
|
c22bb4f853 | ||
|
|
117c091b95 | ||
|
|
6719558150 | ||
|
|
6ffce4bccd | ||
|
|
ea9c58ea80 | ||
|
|
b2c2f1bd49 | ||
|
|
679e56c494 | ||
|
|
3da4323ef3 | ||
|
|
75e5a485d2 | ||
|
|
7bb3a827bb | ||
|
|
96f106319e | ||
|
|
a4ad34841b | ||
|
|
599cd2eeeb | ||
|
|
ee5fd1246c | ||
|
|
1441d0d735 | ||
|
|
e5dbfc420d | ||
|
|
643c661a6f | ||
|
|
ab5dfbda54 | ||
|
|
94302de49b | ||
|
|
45d7486485 | ||
|
|
ee27fd8de1 | ||
|
|
89f154630f | ||
|
|
a6ed0ef9f4 | ||
|
|
aac98120c8 | ||
|
|
44e36e5b0d | ||
|
|
f9ab66f51a | ||
|
|
ce8ed5b5ec | ||
|
|
baef422a28 | ||
|
|
68e257849d | ||
|
|
e686554392 | ||
|
|
96a9696383 | ||
|
|
fa84ff5e12 | ||
|
|
66359d5815 | ||
|
|
c111fa0837 | ||
|
|
6e182940e2 | ||
|
|
bc90463ea6 | ||
|
|
93ed4ae2cd | ||
|
|
a10ac774ab | ||
|
|
26a5d8f75d | ||
|
|
5749f78262 | ||
|
|
1eaae9d934 | ||
|
|
567b0776cd | ||
|
|
72f330133a | ||
|
|
665f95eda3 | ||
|
|
8e2b0b6fd2 | ||
|
|
ce50d9bac4 | ||
|
|
934bebd8cd | ||
|
|
171940869b | ||
|
|
33020d826f | ||
|
|
2c12278444 | ||
|
|
57a2024c58 | ||
|
|
d67fe02263 | ||
|
|
57ec2aa088 | ||
|
|
fa859de460 | ||
|
|
36766f157d | ||
|
|
683438b418 | ||
|
|
4a55167759 | ||
|
|
c5c4aef7b1 | ||
|
|
533c7b27eb | ||
|
|
82af218790 | ||
|
|
b272ca5e88 | ||
|
|
10ba2accf7 | ||
|
|
4c8d4e6dbd | ||
|
|
6359628bc3 | ||
|
|
25fd342261 | ||
|
|
f199c486a2 | ||
|
|
1f205a8441 | ||
|
|
b1d5b3b28e | ||
|
|
32810b4152 | ||
|
|
b7e9992d78 | ||
|
|
6c76983999 | ||
|
|
8bf46dcc5d | ||
|
|
5510fa178e | ||
|
|
5ad593e465 | ||
|
|
dff0141160 | ||
|
|
44da9c6523 | ||
|
|
6ab7d54982 | ||
|
|
0c79a566ac | ||
|
|
34773e795b | ||
|
|
66daa15722 | ||
|
|
db80dd2692 | ||
|
|
0dc74a8a2e | ||
|
|
90a057f400 | ||
|
|
78f856e204 | ||
|
|
d2c695eb11 | ||
|
|
46cf40ec82 | ||
|
|
655420fd25 | ||
|
|
52c73390f8 | ||
|
|
c46ef3b63b | ||
|
|
443908b14c | ||
|
|
3bec320bb9 | ||
|
|
fa6f238777 | ||
|
|
86e6b2b68b | ||
|
|
14e51e0977 | ||
|
|
4c6f100b5f | ||
|
|
5a0488bb18 | ||
|
|
9af40624c5 | ||
|
|
0df561c33c | ||
|
|
c7f996d593 | ||
|
|
907dba4517 | ||
|
|
9e5d6069fe | ||
|
|
14f6747dfc | ||
|
|
68b2872ed6 | ||
|
|
1a4bdd2b30 | ||
|
|
886c12c566 | ||
|
|
a3600e8b21 | ||
|
|
5d48e48e15 | ||
|
|
474427c67e | ||
|
|
00b3583dc2 | ||
|
|
4d9a7cc6c0 | ||
|
|
509bd2bebb | ||
|
|
8c70453b2e | ||
|
|
8eebc2aea6 | ||
|
|
a9a0ce6bea | ||
|
|
ecbdef732b | ||
|
|
4615e8f92b | ||
|
|
91faa9fd5a | ||
|
|
85e92fe3b0 | ||
|
|
38bf0b6eec | ||
|
|
6ae3ddd66b | ||
|
|
98cb2d3411 | ||
|
|
be75bc506a | ||
|
|
7a42efec53 | ||
|
|
e9926694c3 | ||
|
|
5cfb7a08cb | ||
|
|
9d642f6354 | ||
|
|
716f2986b9 | ||
|
|
409f565f09 | ||
|
|
26e95f2a92 | ||
|
|
711a2cd738 | ||
|
|
1c1f72f05c | ||
|
|
1d343aeae4 | ||
|
|
e26f6acc3b | ||
|
|
1555252c4a | ||
|
|
de0cbb9073 | ||
|
|
5a075a2c83 | ||
|
|
7da37b4f66 | ||
|
|
6f80cb6b65 | ||
|
|
9617df04ae | ||
|
|
01d5f42755 | ||
|
|
84d76cccde | ||
|
|
af584b46f4 | ||
|
|
0fb4cceec1 | ||
|
|
1dc353433a | ||
|
|
33e8a09880 | ||
|
|
1cb751d184 | ||
|
|
9e596f8616 | ||
|
|
24044b42ea | ||
|
|
84263fc6a6 | ||
|
|
2faab409d3 | ||
|
|
0b5aa6dd60 | ||
|
|
d0c2bfdbff | ||
|
|
242625782f | ||
|
|
826e9ab317 | ||
|
|
182d5e8591 | ||
|
|
3fc866117d | ||
|
|
b464b48f53 | ||
|
|
2b26355002 | ||
|
|
d81a36310c | ||
|
|
d56bb2c383 | ||
|
|
2dd09223f2 | ||
|
|
0c369d195b | ||
|
|
ab99d3b112 | ||
|
|
3f133fad56 | ||
|
|
41d1ccd39c | ||
|
|
7839d043ff | ||
|
|
9b9e6ce2ab | ||
|
|
81510e9d8f | ||
|
|
c0ff925c2a | ||
|
|
2da661fed1 | ||
|
|
59b128bbda |
@@ -1,3 +1,9 @@
|
||||
<!--
|
||||
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
-->
|
||||
|
||||
# Pull Request Checklist
|
||||
|
||||
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
|
||||
@@ -6,14 +12,16 @@ This is to ensure large feature PRs are discussed with the community first, befo
|
||||
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **Not targeting the `dev` branch will lead to immediate closure of the PR.**
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
|
||||
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||
- [ ] **Documentation:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, or other documentation sources.
|
||||
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
|
||||
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Take this as an opportunity to **make screenshots of the feature/fix and include it in the PR description**.
|
||||
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
|
||||
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
|
||||
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
|
||||
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
|
||||
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
|
||||
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
|
||||
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
|
||||
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
|
||||
- **BREAKING CHANGE**: Significant changes that may affect compatibility
|
||||
- **build**: Changes that affect the build system or external dependencies
|
||||
@@ -76,6 +84,12 @@ This is to ensure large feature PRs are discussed with the community first, befo
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
<!--
|
||||
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||
Keep the "Contributor License Agreement" confirmation text intact.
|
||||
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||
-->
|
||||
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
+237
@@ -5,6 +5,243 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.8.3] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- ✏️ **Model edit shortcut.** Users can now edit models directly from the model selector dropdown menu, making it faster to modify model settings without navigating to separate admin or workspace pages. [Commit](https://github.com/open-webui/open-webui/commit/519ff40cb69cdc1d215cee369e9db70ff7438153)
|
||||
- 🎨 **Image edit API background support.** The image edit API now supports the background parameter for OpenAI's gpt-image-1 model, enabling background transparency control ("transparent", "opaque", "auto") when the feature is exposed in the UI. [#21459](https://github.com/open-webui/open-webui/pull/21459)
|
||||
- ⚡ **Faster model filtering.** Model access control filtering no longer makes a redundant database query to re-fetch model info that is already available in memory, reducing latency when loading model lists for non-admin users. [Commit](https://github.com/open-webui/open-webui/commit/34cd3d79e8688f589e3dd2f03415f8a8f9a13115)
|
||||
- 🔧 **Tool call display improvements.** Tool call results now display arguments in a cleaner key-value format instead of raw JSON, with a responsive layout that shows only the tool name on narrow screens and the full label on wider screens, preventing text wrapping to multiple lines. [Commit](https://github.com/open-webui/open-webui/commit/2ce935bdb10d2b26b230cd54cb649f5c667ed96a)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📧 **USER_EMAIL variable fix.** The {{USER_EMAIL}} template variable now correctly returns the user's email address instead of "Unknown" in prompts. [#21479](https://github.com/open-webui/open-webui/pull/21479), [#21465](https://github.com/open-webui/open-webui/issues/21465)
|
||||
- 🖼️ **Image and file attachment handling fixes.** Uploaded images are now correctly sent to vision-enabled models, and file attachments now work even when no user text is entered alongside a system prompt. This fixes two issues where the backend was not properly processing file attachments: images weren't converted to the expected format for API requests, and file context was dropped when the user sent only a file without accompanying text. [Commit](https://github.com/open-webui/open-webui/commit/f1053d94c7ef7b8b78682dd73586b65a84d202a1), [#21477](https://github.com/open-webui/open-webui/issues/21477), [#21457](https://github.com/open-webui/open-webui/issues/21457)
|
||||
- 🛡️ **Missing function error handling.** Models that reference deleted functions no longer cause the entire /api/models endpoint to crash; instead, the missing functions are skipped and logged, allowing the rest of the models to load successfully. [#21476](https://github.com/open-webui/open-webui/pull/21476), [#21464](https://github.com/open-webui/open-webui/issues/21464)
|
||||
- 🚀 **Startup model pre-fetch error handling.** If model pre-fetching fails during app startup, the application now logs a warning and continues instead of crashing entirely. [Commit](https://github.com/open-webui/open-webui/commit/337109e99ce390f55a9085d0a301853637923779)
|
||||
- ⚙️ **Function module loading error handling.** Function modules that fail to load during startup or model processing are now caught and logged, preventing crashes when models reference functions with loading errors. [Commit](https://github.com/open-webui/open-webui/commit/15b893e651de71b033408e1b713e0b51f6829ab8)
|
||||
- 🗄️ **PostgreSQL group query fix.** The '/api/v1/groups/' endpoint no longer fails with a GROUP BY error when using PostgreSQL; member counts are now calculated using correlated subqueries for better database compatibility. [#21458](https://github.com/open-webui/open-webui/pull/21458), [#21467](https://github.com/open-webui/open-webui/issues/21467)
|
||||
|
||||
## [0.8.2] - 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- 🧠 **Skill content handling.** User-selected skills now have their full content injected into the chat, while model-attached skills only display name and description in the available skills list. This allows users to override skill behavior while model-attached skills remain flexible. [Commit](https://github.com/open-webui/open-webui/commit/393c0071dc612c5ac982fb37dfc0288cb9911439)
|
||||
- ⚙️ **Chat toggles now control built-in tools.** Users can now disable web search, image generation, and code execution on a per-conversation basis, even when those tools are enabled as builtin tools on the model. [#20641](https://github.com/open-webui/open-webui/issues/20641), [#21318](https://github.com/open-webui/open-webui/discussions/21318), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c), [Commit](https://github.com/open-webui/open-webui/commit/f1a1e64d2e9ad953b2bc2a9543e9a308b7c669c8)
|
||||
- 🖼️ **Image preview in file modal.** Images uploaded to chats can now be previewed directly in the file management modal, making it easier to identify and manage image files. [#21413](https://github.com/open-webui/open-webui/issues/21413), [Commit](https://github.com/open-webui/open-webui/commit/e1b3e7252c1896c04d498547908f0fce111434e1)
|
||||
- 🏷️ **Batch tag operations.** Tag creation, deletion, and orphan cleanup for chats now use batch database queries instead of per-tag loops, significantly reducing database round trips when updating, archiving, or deleting chats with multiple tags. [Commit](https://github.com/open-webui/open-webui/commit/c748c3ede)
|
||||
- 💨 **Faster group list loading.** Group lists and search results now load with a single database query that joins member counts, replacing the previous pattern of fetching groups first and then counting members in a separate batch query. [Commit](https://github.com/open-webui/open-webui/commit/33308022f)
|
||||
- 🔐 **Skills sharing permissions.** Administrators can now control skills sharing and public sharing permissions per-group, matching the existing capabilities for tools, knowledge, and prompts. [Commit](https://github.com/open-webui/open-webui/commit/88401e91c)
|
||||
- ⚡ **Long content truncation in preview modals.** Citation and file content modals now truncate markdown-rendered content at 10,000 characters with a "Show all" expansion button, preventing UI jank when previewing very large documents.
|
||||
- 🌐 **Translation updates.** Translations for Spanish and German were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔐 **OAuth session error handling.** Corrupted OAuth sessions are now gracefully handled and automatically cleaned up instead of causing errors. [Commit](https://github.com/open-webui/open-webui/commit/7e224e4a536b07ec008613f06592e34050e7067c)
|
||||
- 🐛 **Task model selector validation.** The task model selector in admin settings now correctly accepts models based on the new access grants system instead of rejecting all models with an incorrect error. [Commit](https://github.com/open-webui/open-webui/commit/9a2595f0706d0c9d809ae7746001cf799f98db1d)
|
||||
- 🔗 **Tool call message preservation.** Models no longer hallucinate tool outputs in multi-turn conversations because tool call history is now properly preserved instead of being merged into assistant messages. [#21098](https://github.com/open-webui/open-webui/discussions/21098), [#20600](https://github.com/open-webui/open-webui/issues/20600), [Commit](https://github.com/open-webui/open-webui/commit/f2aca781c87244cffc130aa2722e700c19a81d66)
|
||||
- 🔧 **Tool server startup initialization.** External tool servers configured via the "TOOL_SERVER_CONNECTIONS" environment variable now initialize automatically on startup, eliminating the need to manually visit the Admin Panel and save for tools to become available. This enables proper GitOps and containerized deployments. [#18140](https://github.com/open-webui/open-webui/issues/18140), [#20914](https://github.com/open-webui/open-webui/pull/20914), [Commit](https://github.com/open-webui/open-webui/commit/f20cc6d7e6da493eb75ca1618f5cbd068fa57684)
|
||||
- ♻️ **Resource handle cleanup.** File handles are now properly closed during audio transcription and pipeline uploads, preventing resource leaks that could cause system instability over time. [#21411](https://github.com/open-webui/open-webui/issues/21411)
|
||||
- ⌨️ **Strikethrough shortcut conflict fix.** Pressing Ctrl+Shift+S to toggle the sidebar no longer causes text to become struck through in the chat input, by disabling the TipTap Strike extension's default keyboard shortcut when rich text mode is off. [Commit](https://github.com/open-webui/open-webui/commit/38ae91ae2)
|
||||
- 🔧 **Tool call finish_reason fix.** API responses now correctly set finish_reason to "tool_calls" instead of "stop" when tool calls are present, fixing an issue where external API clients (such as OpenCode) would halt prematurely after tool execution when routing Ollama models through the Open WebUI API. [#20896](https://github.com/open-webui/open-webui/issues/20896)
|
||||
|
||||
## [0.8.1] - 2026-02-14
|
||||
|
||||
### Added
|
||||
|
||||
- 🚀 **Channel user active status.** Checking user active status in channels is now faster thanks to optimized database queries. [Commit](https://github.com/open-webui/open-webui/commit/ca6b18ab5cb94153a9dae233f975d36bf6b19b76)
|
||||
- 🔗 **Responses API endpoint with model routing.** The OpenAI API proxy now supports a /responses endpoint that routes requests to the correct backend based on the model field in the request, instead of always using the first configured endpoint. This enables support for backends like vLLM that provide /skills and /v1/responses endpoints. [Commit](https://github.com/open-webui/open-webui/commit/abc9b63093d65f4d74342db85b7d5df1809aa0f0), [Commit](https://github.com/open-webui/open-webui/commit/79ecbfc757f0642740d0e44fab98263d84295490)
|
||||
- ⚡ **Model and prompt list optimization.** Improved performance when loading models and prompts by pre-fetching user group IDs once instead of making multiple database queries. [Commit](https://github.com/open-webui/open-webui/commit/20de5a87da0c12e4052b50887a42ddd7228c5ef5)
|
||||
- 🗄️ **Batch access control queries.** Improved performance when loading models, prompts, and knowledge bases by replacing multiple individual access checks with single batch queries, significantly reducing database load for large deployments. [Commit](https://github.com/open-webui/open-webui/commit/589c4e64c1b7bb7a7a5abc20382b92fb860e28c2)
|
||||
- 💨 **Faster user list loading.** User lists now load significantly faster by deferring profile image loading; images are fetched separately in parallel by the browser, improving caching and reducing database load. [Commit](https://github.com/open-webui/open-webui/commit/b7549d2f6ca2843661ec79a5a1e55da9e7553368)
|
||||
- 🔍 **Web search result count.** The built-in search_web tool now respects the admin-configured "Search Result Count" setting instead of always returning 5 results when using Native Function Calling mode. [#21373](https://github.com/open-webui/open-webui/pull/21373), [#21371](https://github.com/open-webui/open-webui/issues/21371)
|
||||
- 🔐 **SCIM externalId support.** SCIM-enabled deployments can now store and manage externalId for user provisioning, enabling better integration with identity providers like Microsoft Entra ID and Okta. [#21099](https://github.com/open-webui/open-webui/pull/21099), [#21280](https://github.com/open-webui/open-webui/issues/21280), [Commit](https://github.com/open-webui/open-webui/commit/d1d1efe212b16e0052359991d67fd813125077e8)
|
||||
- 🌐 **Translation updates.** Portuguese (Brazil) translations were updated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Public sharing security fix.** Fixed a security issue where users with write access could see the Public sharing option regardless of their actual public sharing permission, and direct API calls could bypass frontend sharing restrictions. [#21358](https://github.com/open-webui/open-webui/pull/21358), [#21356](https://github.com/open-webui/open-webui/issues/21356)
|
||||
- 🔒 **Direct model access control fix.** Model access control changes now persist correctly for direct Ollama and OpenAI models that don't have database entries, and error messages display properly instead of showing "[object Object]". [Commit](https://github.com/open-webui/open-webui/commit/f027a01ab2ff3b6175af3dd13a4478c265c0544a), [#21377](https://github.com/open-webui/open-webui/issues/21377)
|
||||
- 💭 **Reasoning trace rendering performance.** Reasoning traces from models now render properly without being split into many fragments, preventing browser slowdowns during streaming responses. [#21348](https://github.com/open-webui/open-webui/issues/21348), [Commit](https://github.com/open-webui/open-webui/commit/3b61562c82448cf83710d8b6ed29b797991aa83a)
|
||||
- 🖥️ **ARM device compatibility fix.** Fixed an issue where upgrading to 0.8.0 would fail to start on ARM devices (like Raspberry Pi 4) due to torch 2.10.0 causing SIGILL errors; now pinned to torch<=2.9.1. [#21385](https://github.com/open-webui/open-webui/pull/21385), [#21349](https://github.com/open-webui/open-webui/issues/21349)
|
||||
- 🗄️ **Skills PostgreSQL compatibility fix.** Fixed a PostgreSQL compatibility issue where creating or listing skills would fail with a TypeError, while SQLite worked correctly. [#21372](https://github.com/open-webui/open-webui/pull/21372), [Commit](https://github.com/open-webui/open-webui/commit/b4c3f54f9648c4232a0fd6557703ffa66fcf4caa), [#21365](https://github.com/open-webui/open-webui/issues/21365)
|
||||
- 🗄️ **PostgreSQL analytics query fix.** Fixed an issue where retrieving chat IDs by model ID would fail on PostgreSQL due to incompatible DISTINCT ordering, while SQLite worked correctly. [#21347](https://github.com/open-webui/open-webui/issues/21347), [Commit](https://github.com/open-webui/open-webui/commit/7bda6bf767d5d5c4dc1111465096a88e10b5030e)
|
||||
- 🗃️ **SQLite cascade delete fix.** Deleting chats now properly removes all associated messages in SQLite, matching PostgreSQL behavior and preventing orphaned data. [#21362](https://github.com/open-webui/open-webui/pull/21362)
|
||||
- ☁️ **Ollama Cloud model naming fix.** Fixed an issue where using Ollama Cloud models would fail with "Model not found" errors because ":latest" was incorrectly appended to model names. [#21386](https://github.com/open-webui/open-webui/issues/21386)
|
||||
- 🛠️ **Knowledge selector tooltip z-index.** Fixed an issue where tooltips in the "Select Knowledge" dropdown were hidden behind the menu, making it difficult to read knowledge item names and descriptions. [#21375](https://github.com/open-webui/open-webui/pull/21375)
|
||||
- 🎯 **Model selector scroll position.** The model selector dropdown now correctly scrolls to and centers the currently selected model when opened, and resets scroll position when reopened. [Commit](https://github.com/open-webui/open-webui/commit/0b05b2fc7ed4c38af158707438ff404d1beb7c91)
|
||||
- 🐛 **Sync modal unexpected appearance.** Fixed an issue where the Sync Modal would appear unexpectedly after enabling the "Community Sharing" feature if the user had previously visited the app with the sync parameter. [#21376](https://github.com/open-webui/open-webui/pull/21376)
|
||||
- 🎨 **Knowledge collection layout fix.** Fixed a layout issue in the Knowledge integration menu where long collection names caused indentation artifacts and now properly truncate with ellipsis. [#21374](https://github.com/open-webui/open-webui/pull/21374)
|
||||
- 📝 **Metadata processing crash fix.** Fixed a latent bug where processing document metadata containing certain keys (content, pages, tables, paragraphs, sections, figures) would cause a RuntimeError due to dictionary mutation during iteration. [#21105](https://github.com/open-webui/open-webui/pull/21105)
|
||||
- 🔑 **Password validation regex fix.** Fixed the password validation regex by adding the raw string prefix, ensuring escape sequences like \d and \w are interpreted correctly. [#21400](https://github.com/open-webui/open-webui/pull/21400), [#21399](https://github.com/open-webui/open-webui/issues/21399)
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚠️ **Database Migrations:** This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
|
||||
|
||||
## [0.8.0] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- 📊 **Analytics dashboard.** Administrators now have access to an Analytics dashboard showing model usage statistics, token consumption by model and user, user activity rankings, and time-series charts with hourly or daily granularity; clicking any model opens a detail view with feedback history, associated tags, and chat browser, and results can be filtered by user group. [#21106](https://github.com/open-webui/open-webui/pull/21106), [Commit](https://github.com/open-webui/open-webui/commit/68a1e87b66a7ec8831d5ed52940c4ef110e3e264), [Commit](https://github.com/open-webui/open-webui/commit/e62649f94044abfed4d7d60647a2050383a67e3d)
|
||||
- 🎯 **Experimental support for Skills.** Open WebUI now supports the Skill standard — allowing users to create and manage reusable AI skills with detailed instructions, reference them in chats using the "$" command, or attach them to specific models for automatic context in conversations. [#21312](https://github.com/open-webui/open-webui/pull/21312)
|
||||
- 🧪 **Experimental support for Open Responses protocol.** Connections can now be configured to use the experimental Open Responses protocol instead of Chat Completions, enabling native support for extended thinking, streaming reasoning tokens, and richer tool call handling for compatible providers. [Commit](https://github.com/open-webui/open-webui/commit/d2c695eb11ddca9fc93499bb0c3fcafcff7099b5), [Commit](https://github.com/open-webui/open-webui/commit/90a057f4005c000bda6ff8703e13e529190af73a), [Commit](https://github.com/open-webui/open-webui/commit/0dc74a8a2e7adb76fb503ef0cd3c02daddd2f4bb), [Commit](https://github.com/open-webui/open-webui/commit/ea9c58ea80646cef05e06d0beaf5e81cc2f78cb1), [Commit](https://github.com/open-webui/open-webui/commit/6ffce4bccdc13b8b61a8b286e34094c981932eda), [Commit](https://github.com/open-webui/open-webui/commit/6719558150920f570d8febe021da65903e53c976), [Commit](https://github.com/open-webui/open-webui/commit/117c091b95a1b1a76a31c31b97304bac289d6f18), [Commit](https://github.com/open-webui/open-webui/commit/aa8c2959ca8476f269786e1317fb6d2938abd3f9), [Commit](https://github.com/open-webui/open-webui/commit/e2d09ac36174de48a7d85bafc8d3291c9ffe44cd)
|
||||
- 👥 **Redesigned access control UI.** The access control UI was redesigned with a more intuitive interface that makes it easier to add multiple groups at once. [#21277](https://github.com/open-webui/open-webui/pull/21277)
|
||||
- 👤 **Per-user resource sharing.** Resources including knowledge bases, prompts, models, tools, channels, and base models can now be shared directly to individual users alongside the existing per-group sharing capability. [#21277](https://github.com/open-webui/open-webui/pull/21277)
|
||||
- 📨 **Message queuing.** Messages can now be queued while a response is generating rather than being blocked, allowing you to continue your train of thought; queued messages are automatically combined and sent when generation completes, and can be edited, deleted, or sent immediately from the input area. [Commit](https://github.com/open-webui/open-webui/commit/62750b8980ef0a3f2da7bc64b5416706a7495686), [Commit](https://github.com/open-webui/open-webui/commit/d3f2cf74748db42311ca04a56ccd1ea15399eca0)
|
||||
- 💡 **Active task sidebar indicator.** Users can now see which chats have active tasks running directly in the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/48522271586a5bf24b649610f03b4ffd8afb2782)
|
||||
- 📝 **Prompt version control.** Prompts now include version control with full history tracking, allowing users to commit changes with messages, view past versions, compare differences between versions, and roll back to previous versions when needed. [#20945](https://github.com/open-webui/open-webui/pull/20945)
|
||||
- 🏷️ **Prompt tags.** Prompts can now be organized with tags, and users can filter the prompt workspace by tag to quickly find related prompts across large collections. [#20945](https://github.com/open-webui/open-webui/pull/20945)
|
||||
- 🐍 **Native function calling code execution.** Code execution now works with Native function calling mode, allowing models to autonomously run Python code for calculations, data analysis, and visualizations without requiring Default mode. [#20592](https://github.com/open-webui/open-webui/pull/20592), [Docs:#998](https://github.com/open-webui/docs/pull/998)
|
||||
- 🚀 **Async web search.** Web search operations now run asynchronously in the background, allowing users to continue interacting with the application while searches complete. [#20630](https://github.com/open-webui/open-webui/pull/20630)
|
||||
- ⚡ **Search debouncing.** Search operations across the application now respond more efficiently with debouncing that reduces unnecessary server requests while typing, improving responsiveness when searching users, groups, functions, tools, prompts, knowledge bases, notes, and when using the knowledge and prompts commands in chat. [#20982](https://github.com/open-webui/open-webui/pull/20982), [Commit](https://github.com/open-webui/open-webui/commit/36766f157d46102fd76c526b42579400ca70de50), [Commit](https://github.com/open-webui/open-webui/commit/fa859de460376782bd0fa35512c8426c9cd0462c), [Commit](https://github.com/open-webui/open-webui/commit/57ec2aa088ffd5a8c3553c53d39799497ff70479)
|
||||
- 🤝 **Shared chats management.** Users can now view and manage all their shared chats from Settings, with options to copy share links or unshare conversations they no longer want public. [Commit](https://github.com/open-webui/open-webui/commit/a10ac774ab5d47b505e840b029c0c0340002508b)
|
||||
- 📁 **User file management.** Users can now view, search, and delete all their uploaded files from Settings, providing centralized file management in one place. [Commit](https://github.com/open-webui/open-webui/commit/93ed4ae2cda2f4311143e51f586aaa73b83a37a7), [#21047](https://github.com/open-webui/open-webui/pull/21047)
|
||||
- 🗑️ **Shift-click quick delete.** Files in the File Manager can now be quickly deleted by holding Shift and clicking the delete button, bypassing the confirmation dialog for faster bulk cleanup. [#21044](https://github.com/open-webui/open-webui/pull/21044)
|
||||
- ⌨️ **Model selector shortcut.** The model selector can now be opened with Ctrl+Shift+M keyboard shortcut. [#21130](https://github.com/open-webui/open-webui/pull/21130)
|
||||
- 🧠 **Smarter knowledge vs web search.** Models now choose more intelligently between knowledge base search and web search rather than always trying knowledge first. [#21115](https://github.com/open-webui/open-webui/pull/21115)
|
||||
- 🌍 **Community model reviews.** Users can now access community reviews for models directly from the model selector menu and are prompted to leave reviews after rating responses, with administrators able to disable this via the "Community Sharing" setting. [Commit](https://github.com/open-webui/open-webui/commit/bc90463ea60c9a66accb1fd242cf1853910ca838)
|
||||
- 📄 **Prompts workspace pagination.** The prompts workspace now includes pagination for large prompt collections, loading 30 prompts at a time with search, filtering, and sorting capabilities for improved performance and navigation. [Commit](https://github.com/open-webui/open-webui/commit/36766f157d46102fd76c526b42579400ca70de50)
|
||||
- 🎨 **Action function HTML rendering.** Action functions can now render rich HTML content directly in chat as embedded iframes, matching the capabilities that tools already had and eliminating the need for action authors to inject codeblocks. [#21294](https://github.com/open-webui/open-webui/pull/21294), [Commit](https://github.com/open-webui/open-webui/commit/60ada21c152ed642971429fdbe88dcbf478cf83a)
|
||||
- 🔒 **Password-masked valve fields.** Tool and function developers can now mark sensitive fields as passwords, which are automatically masked in the settings UI to prevent shoulder surfing and accidental exposure. [#20852](https://github.com/open-webui/open-webui/issues/20852), [Commit](https://github.com/open-webui/open-webui/commit/8c70453b2e3a6958437d951751e84acbbaafd9aa)
|
||||
- 📋 **Prompt quick copy.** Prompts in the workspace now include a quick copy button for easily copying prompt content to the clipboard. [Commit](https://github.com/open-webui/open-webui/commit/78f856e2049991441a3469230ae52799cb86954e)
|
||||
- 🔔 **Dismissible notification toasts.** Notification toasts for new messages and other events now include a close button that appears on hover, allowing users to dismiss them immediately instead of waiting for auto-dismissal. [#21056](https://github.com/open-webui/open-webui/issues/21056), [Commit](https://github.com/open-webui/open-webui/commit/73bb600034c8532e30726129743a5ffe9002c5fb)
|
||||
- 🔔 **Temporary chat notification privacy.** Notifications from temporary chats now only appear on the device where the chat is running, preventing privacy leaks across logged-in sessions. [#21292](https://github.com/open-webui/open-webui/pull/21292)
|
||||
- 💡 **Null chat title fallback.** Notifications without chat titles now display "New Chat" instead of showing null. [#21292](https://github.com/open-webui/open-webui/pull/21292)
|
||||
- 🖼️ **Concurrent image editing.** Image editing operations with multiple images now complete faster by loading all images concurrently instead of sequentially. [#20911](https://github.com/open-webui/open-webui/pull/20911)
|
||||
- 📧 **USER_EMAIL template variable.** Users can now reference their email address in prompts and system messages using the "{{USER_EMAIL}}" template variable. [#20881](https://github.com/open-webui/open-webui/pull/20881)
|
||||
- 🔤 **Alphabetical tool ordering.** Tools and Functions in the Chat Controls sidebar now appear in alphabetical order, making it easier to locate specific tools when working with multiple integrations. [#20871](https://github.com/open-webui/open-webui/pull/20871)
|
||||
- 👁️ **Model list status filtering.** Administrators can now filter the model list by status (enabled, disabled, visible, hidden) and bulk enable or disable all filtered models at once. [#20553](https://github.com/open-webui/open-webui/issues/20553), [#20774](https://github.com/open-webui/open-webui/issues/20774), [Commit](https://github.com/open-webui/open-webui/commit/96a9696383d450dad2cbb230f3756ebfa258e029)
|
||||
- ⚙️ **Per-model built-in tool toggles.** Administrators can now enable or disable individual built-in tools for each model, including time utilities, memory, chat history, notes, knowledge base, and channels. [#20641](https://github.com/open-webui/open-webui/issues/20641), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c)
|
||||
- 📑 **PDF loading modes.** Administrators can now choose between "page" and "single" PDF loading modes, allowing documents to be processed as individual pages or as complete documents for better chunking across page boundaries. [Commit](https://github.com/open-webui/open-webui/commit/ecbdef732bc71a07c21bbb679edb420f26eac181)
|
||||
- 📑 **Model Settings pagination.** Administrators can now navigate large model lists more efficiently in Model Settings, with pagination displaying 30 models per page for smoother navigation. [Commit](https://github.com/open-webui/open-webui/commit/2f584c9f88aeb34ece07b10d05794020d1d656b8)
|
||||
- 📌 **Pin read-only models.** Users can now pin read-only models from the workspace. [#21308](https://github.com/open-webui/open-webui/issues/21308), [Commit](https://github.com/open-webui/open-webui/commit/97331bf11d41ca54e47f86777fb8dbd73988c631)
|
||||
- 🔍 **Yandex search provider.** Administrators can now configure Yandex as a web search provider, expanding search engine options for retrieval-augmented generation. [#20922](https://github.com/open-webui/open-webui/pull/20922)
|
||||
- 🔐 **Custom password hints.** Administrators can now provide custom password requirement hints to users via the "PASSWORD_VALIDATION_HINT" environment variable, making it clearer what password criteria must be met during signup or password changes. [#20647](https://github.com/open-webui/open-webui/issues/20647), [#20650](https://github.com/open-webui/open-webui/pull/20650)
|
||||
- 🔑 **OAuth token exchange.** Administrators can now enable OAuth token exchange via "ENABLE_OAUTH_TOKEN_EXCHANGE", allowing external applications to authenticate users by exchanging OAuth provider tokens for Open WebUI session tokens. [Commit](https://github.com/open-webui/open-webui/commit/655420fd25ed0ea872954baa485030079c00c10e)
|
||||
- 🗄️ **Weaviate custom endpoints.** Administrators can now connect to self-hosted Weaviate deployments with separate HTTP and gRPC endpoints via new environment variables. [#20620](https://github.com/open-webui/open-webui/pull/20620)
|
||||
- 🛡️ **MCP custom SSL certificates.** Administrators can now connect to MCP servers with self-signed or custom SSL certificates via the "AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL" environment variable. [#20875](https://github.com/open-webui/open-webui/issues/20875), [Commit](https://github.com/open-webui/open-webui/commit/c7f996d593e4bb48103b91316204fe7e50e25b35)
|
||||
- 🗃️ **Redis Sentinel reconnection delay.** Administrators using Redis Sentinel can now configure a reconnection delay via "REDIS_RECONNECT_DELAY" to prevent retry exhaustion during failover elections. [#21021](https://github.com/open-webui/open-webui/pull/21021)
|
||||
- 📡 **Custom user info headers.** Administrators can now customize the header names used when forwarding user information to external services, enabling compatibility with services like AWS Bedrock AgentCore that require specific header prefixes. [Commit](https://github.com/open-webui/open-webui/commit/6c0f886cdf4b4249dca29e9340b3b998a7262d61)
|
||||
- 🔗 **Forward user info to tool servers.** User identity and chat context can now be forwarded to MCP servers and external tool servers when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled, allowing tool providers to implement per-user authorization, auditing, and rate limiting. [#21092](https://github.com/open-webui/open-webui/pull/21092), [Commit](https://github.com/open-webui/open-webui/commit/2c37daef86a058e370151ecead17f10078102307)
|
||||
- 📬 **External tool event emitters.** External tools (OpenAPI/MCP) can now send tool events back to Open WebUI using the event emitter endpoint, as message ID is now forwarded alongside chat ID when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled. [#21214](https://github.com/open-webui/open-webui/pull/21214)
|
||||
- 📥 **Playground chat export.** Administrators can now export playground chats as JSON or plain text files, allowing them to save their conversations for backup or sharing outside the platform. [Commit](https://github.com/open-webui/open-webui/commit/8e2b0b6fd2ac99c833a110e2bc6aa655f1682669)
|
||||
- 🖼️ **Images playground.** Administrators can now test image generation and editing directly in a new Images playground, with support for uploading source images for edits and downloading results. [Commit](https://github.com/open-webui/open-webui/commit/94302de49b27bdf1df86b5c26f2cafb98f964e52)
|
||||
- 🛠️ **Dynamic dropdown valve fields.** Tool and function developers can now create dropdown fields with dynamically-generated options that update based on runtime context, such as available models or user permissions. [Commit](https://github.com/open-webui/open-webui/commit/474427c67e953bb9f7d122757a756a639214e0b2)
|
||||
- 🏎️ **Faster profile updates.** User profile updates and role changes are now faster by eliminating redundant database queries. [#21011](https://github.com/open-webui/open-webui/pull/21011)
|
||||
- 🔑 **Faster authentication.** User authentication is now 34% faster by combining database lookups into a single query. [#21010](https://github.com/open-webui/open-webui/pull/21010)
|
||||
- 🔋 **Faster chat completions.** Chat completions and embeddings now respond much faster by checking the model cache before fetching model lists, reducing Time To First Token from several seconds to subsecond for most requests. [#20886](https://github.com/open-webui/open-webui/pull/20886), [#20069](https://github.com/open-webui/open-webui/discussions/20069)
|
||||
- 🏎️ **Faster Redis model list loading.** Model list loading is now significantly faster when using Redis with many models, reducing API response latency by caching configuration values locally instead of making repeated Redis lookups on every model iteration. [#21306](https://github.com/open-webui/open-webui/pull/21306)
|
||||
- 💨 **Faster knowledge base file batch-add.** Batch-adding files to knowledge bases is now faster with a single database query instead of one query per file. [#21006](https://github.com/open-webui/open-webui/pull/21006)
|
||||
- ⚡ **Smoother model selector dropdown.** The model selector dropdown now renders smoothly even with hundreds of models, eliminating the lag and freezing that occurred when opening the dropdown with large model lists. [Commit](https://github.com/open-webui/open-webui/commit/4331029926245b7b74fa8e254610c91400b239b0)
|
||||
- 🚗 **Faster model visibility toggling.** Toggling model visibility in the admin panel is now faster with optimized database access. [#21009](https://github.com/open-webui/open-webui/pull/21009)
|
||||
- 💾 **Faster model access control checks.** Model access control checks are now faster by batch-fetching model info and group memberships upfront instead of querying for each model. [#21008](https://github.com/open-webui/open-webui/pull/21008)
|
||||
- ⚙️ **Faster model list and imports.** Model list loading and model imports are now faster by eliminating redundant database queries. [#21004](https://github.com/open-webui/open-webui/pull/21004)
|
||||
- 🏃 **Faster SCIM group member lookups.** SCIM group member lookups are now up to 13x faster by batching user queries instead of fetching each member individually. [#21005](https://github.com/open-webui/open-webui/pull/21005)
|
||||
- 💨 **Batched group member counts.** Group member counts are now fetched in a single batch query when loading group lists, eliminating redundant database lookups. [Commit](https://github.com/open-webui/open-webui/commit/96c07f44a8f5e6346b2ea6ac529ff4ec3c47e90a)
|
||||
- 💨 **Faster bulk operations.** Bulk feedback deletion and group member removal are now 4-5x faster with optimized batch operations. [#21019](https://github.com/open-webui/open-webui/pull/21019)
|
||||
- 🧠 **Faster memory updates.** Memory updates are now up to 39% faster by eliminating redundant database queries. [#21013](https://github.com/open-webui/open-webui/pull/21013)
|
||||
- ⚙️ **Faster filter function loading.** Filter function loading is now faster by batching database queries instead of fetching each function individually. [#21018](https://github.com/open-webui/open-webui/pull/21018)
|
||||
- 🖼️ **Image model regex configuration.** Administrators can now configure which image generation models support auto-sizing and URL responses via new regex environment variables, improving compatibility with LiteLLM and other proxies that use prefixed model names. [#21126](https://github.com/open-webui/open-webui/pull/21126), [Commit](https://github.com/open-webui/open-webui/commit/ecf3fa2feb28e74ff6c17ca97d94581f316da56a)
|
||||
- 🎁 **Easter eggs toggle.** Administrators can now control the visibility of easter egg features via the "ENABLE_EASTER_EGGS" environment variable. [Commit](https://github.com/open-webui/open-webui/commit/907dba4517903e5646e40223a0edca26a7107bc8)
|
||||
- 🔌 **Independent access control updates.** API endpoints now support independent access control updates for models, tools, knowledge bases, and notes, enabling finer-grained permission management. [Commit](https://github.com/open-webui/open-webui/commit/0044902c082f8475336cc7d5c57fe3f35ab0555d), [Commit](https://github.com/open-webui/open-webui/commit/c259c878060af1b03b702c943e8813d7b4fc3199), [Commit](https://github.com/open-webui/open-webui/commit/e3a825769063cee486650cc2eb9a032676e630c5)
|
||||
- ♿ **Screen reader accessibility.** Screen reader users now hear the password field label only once on the login page, improving form navigation for assistive technology users. [Commit](https://github.com/open-webui/open-webui/commit/1441d0d735c7a1470070b33327e1dd4dc5ca1131)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Catalan, Finnish, Irish, French, German, Japanese, Latvian, Polish, Portuguese (Brazil), Simplified Chinese, Slovak, Spanish, and Traditional Chinese were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚡ **Connection pool exhaustion fix.** Database connection pool exhaustion and timeout errors during concurrent usage have been resolved by releasing connections before chat completion requests and embedding operations for memory and knowledge base processing. [#20569](https://github.com/open-webui/open-webui/pull/20569), [#20570](https://github.com/open-webui/open-webui/pull/20570), [#20571](https://github.com/open-webui/open-webui/pull/20571), [#20572](https://github.com/open-webui/open-webui/pull/20572), [#20573](https://github.com/open-webui/open-webui/pull/20573), [#20574](https://github.com/open-webui/open-webui/pull/20574), [#20575](https://github.com/open-webui/open-webui/pull/20575), [#20576](https://github.com/open-webui/open-webui/pull/20576), [#20577](https://github.com/open-webui/open-webui/pull/20577), [#20578](https://github.com/open-webui/open-webui/pull/20578), [#20579](https://github.com/open-webui/open-webui/pull/20579), [#20580](https://github.com/open-webui/open-webui/pull/20580), [#20581](https://github.com/open-webui/open-webui/pull/20581), [Commit](https://github.com/open-webui/open-webui/commit/7da37b4f66b9b2e821796b06b75e03cb0237e0a9), [Commit](https://github.com/open-webui/open-webui/commit/9af40624c5f0f8f7f640a11356e167543b07b2bb)
|
||||
- 🚫 **LDAP authentication hang fix.** LDAP authentication no longer freezes the entire service when logging in with non-existent accounts, preventing application hangs. [Commit](https://github.com/open-webui/open-webui/commit/a4281f6a7fbc9764b57830e4ef81bb780aa34af9), [#21300](https://github.com/open-webui/open-webui/issues/21300)
|
||||
- ✅ **Trusted Header auto-registration fix.** Trusted Header Authentication now properly auto-registers new users after the first login, assigning the configured default role instead of failing for users not yet in the database. [Commit](https://github.com/open-webui/open-webui/commit/9b30e8f6894c8c8bad0a9ce4693eab810962adc9)
|
||||
- 🛡️ **SSRF protection for image loading.** External image loading now validates URLs before fetching to prevent SSRF attacks against local and private network addresses. [Commit](https://github.com/open-webui/open-webui/commit/ce50d9bac4f30b054b09a2fbda52569b73ea591c)
|
||||
- 🛡️ **Malformed Authorization header fix.** Malformed Authorization headers no longer cause server crashes; requests are now handled gracefully instead of returning HTTP 500 errors. [#20938](https://github.com/open-webui/open-webui/issues/20938), [Commit](https://github.com/open-webui/open-webui/commit/7e79f8d1c6b5a02f1a46e792540c6bbf7bed8edc)
|
||||
- 🚪 **Channel notification access control.** Users without channel permissions can no longer access channels through notifications, properly enforcing access controls across all channel entry points. [#20883](https://github.com/open-webui/open-webui/pull/20883), [#20789](https://github.com/open-webui/open-webui/discussions/20789)
|
||||
- 🐛 **Ollama model name suffix fix.** Ollama-compatible providers that do not use ":latest" in model names can now successfully chat, fixing errors where model names were incorrectly appended with ":latest" suffixes. [#21331](https://github.com/open-webui/open-webui/issues/21331), [Commit](https://github.com/open-webui/open-webui/commit/05ae44b98dc279ee12cc8eab17278ccbfec60301)
|
||||
- ♻️ **Streaming connection cleanup.** Streaming responses now properly clean up network connections when interrupted, preventing "Unclosed client session" errors from accumulating over time. [#20889](https://github.com/open-webui/open-webui/pull/20889), [#17058](https://github.com/open-webui/open-webui/issues/17058)
|
||||
- 💾 **Inline image context exhaustion fix.** Inline images no longer exhaust the model's context window by including their full base64 data in chat metadata, preventing premature context exhaustion with image-heavy conversations. [#20916](https://github.com/open-webui/open-webui/pull/20916)
|
||||
- 🚀 **Status indicator GPU usage fix.** High GPU usage caused by the user online status indicator animation has been resolved, reducing consumption from 35-40% to near-zero in browsers with hardware acceleration. [#21062](https://github.com/open-webui/open-webui/issues/21062), [Commit](https://github.com/open-webui/open-webui/commit/938d1b0743c64f0ce513d68e57dfbb86987cb06b)
|
||||
- 🔧 **Async pipeline operations.** Pipeline operations now run asynchronously instead of blocking the FastAPI event loop, allowing the server to handle other requests while waiting for external pipeline API calls. [#20910](https://github.com/open-webui/open-webui/pull/20910)
|
||||
- 🔌 **MCP tools regression fix.** MCP tools now work reliably again after a regression in v0.7.2 that caused "cannot pickle '\_asyncio.Future' object" errors when attempting to use MCP servers in chat. [#20629](https://github.com/open-webui/open-webui/issues/20629), [#20500](https://github.com/open-webui/open-webui/issues/20500), [Commit](https://github.com/open-webui/open-webui/commit/886c12c5664bc2dd73313330f61c2257169da6d1)
|
||||
- 🔗 **Function chat ID propagation fix.** Functions now reliably receive the chat identifier during internal task invocations like web search query generation, RAG query generation, and image prompt generation, enabling stateful functions to maintain consistent per-chat state without fragmentation. [#20563](https://github.com/open-webui/open-webui/issues/20563), [#20585](https://github.com/open-webui/open-webui/pull/20585)
|
||||
- 💻 **Markdown fence code execution fix.** Code execution now works reliably when models wrap code in markdown fences, automatically stripping the backticks before execution to prevent syntax errors that affected most non-GPT models. [#20941](https://github.com/open-webui/open-webui/issues/20941), [Commit](https://github.com/open-webui/open-webui/commit/4a5516775927aaf002212f2e09c55a17c699bc46), [Commit](https://github.com/open-webui/open-webui/commit/683438b418fb3b453a8ad88c1ba1a9944eac3593)
|
||||
- 💻 **ANSI code execution fix.** Code execution is now reliable when LLMs include ANSI terminal color codes in their output, preventing random failures that previously caused syntax errors. [#21091](https://github.com/open-webui/open-webui/issues/21091), [Commit](https://github.com/open-webui/open-webui/commit/b1737040a7d3bb5efcfe0f1432e89d7e82e51d2d)
|
||||
- 🗨️ **Incomplete model metadata crash fix.** Starting chats with models that have incomplete metadata information no longer crashes the application. [#20565](https://github.com/open-webui/open-webui/issues/20565), [Commit](https://github.com/open-webui/open-webui/commit/14f6747dfc66fb7e942b930650286012121e5262)
|
||||
- 💬 **Unavailable model crash fix.** Adding message pairs with Ctrl+Shift+Enter no longer crashes when the chat's model is unavailable, showing a helpful error message instead. [#20663](https://github.com/open-webui/open-webui/pull/20663)
|
||||
- 📚 **Knowledge base file upload fix.** Uploading files to knowledge bases now works correctly, fixing database mapping errors that prevented file uploads. [#20925](https://github.com/open-webui/open-webui/issues/20925), [#20931](https://github.com/open-webui/open-webui/pull/20931)
|
||||
- 🧠 **Knowledge base query type fix.** Knowledge base queries no longer fail intermittently when models send tool call parameters as strings instead of their expected types. [#20705](https://github.com/open-webui/open-webui/pull/20705)
|
||||
- 📚 **Knowledge base reindex fix.** Reindexing knowledge base files now works correctly instead of failing with duplicate content errors. [#20854](https://github.com/open-webui/open-webui/issues/20854), [#20857](https://github.com/open-webui/open-webui/pull/20857)
|
||||
- 🔧 **Multi-worker knowledge base timeout fix.** In multi-worker deployments, uploading very large documents to knowledge bases no longer causes workers to be killed by health check timeouts, and administrators can now configure a custom embedding timeout via "RAG_EMBEDDING_TIMEOUT". [#21158](https://github.com/open-webui/open-webui/pull/21158), [Discussion](https://github.com/open-webui/open-webui/discussions/21151), [Commit](https://github.com/open-webui/open-webui/commit/c653e4ec54d070aee5e9568d016daebb61f06632)
|
||||
- 🌅 **Dark mode icon inversion fix.** Icons in chat and action menus are now displayed correctly in dark mode, fixing an issue where PNG icons with "svg" in their base64 encoding were randomly inverted. [#21272](https://github.com/open-webui/open-webui/pull/21272), [Commit](https://github.com/open-webui/open-webui/commit/0a44d80252afae73de4098ab1c3eb6cf54157fd6)
|
||||
- 🛠️ **Admin model write permission fix.** Fixed the admin panel allowing models to be assigned write permissions, since users with write permission are not admins and cannot write. [Commit](https://github.com/open-webui/open-webui/commit/4aedfdc5471a1f13c1084b34b48ea3ed6311cd42)
|
||||
- 🛠️ **Prompt access control save fix.** Prompt access control settings are now saved correctly when modifying resource permissions. [Commit](https://github.com/open-webui/open-webui/commit/30f72672fac2579c267a076e6ba89dfe1812137b)
|
||||
- ✏️ **Knowledge base file edit fix.** Editing files within knowledge bases now saves correctly and can be used for retrieval, fixing a silent failure where the save appeared successful but the file could not be searched. [Commit](https://github.com/open-webui/open-webui/commit/f9ab66f51a52388a4eb084c8f69044e79bf5cb04)
|
||||
- 🖼️ **Reasoning section artifact rendering fix.** Code blocks within model reasoning sections no longer incorrectly render as interactive artifacts, ensuring only intended output displays as previews. [#20801](https://github.com/open-webui/open-webui/issues/20801), [#20877](https://github.com/open-webui/open-webui/pull/20877), [Commit](https://github.com/open-webui/open-webui/commit/4c6f100b5fe2145a3d676b70b5f7c0e7f07cee20)
|
||||
- 🔐 **Group resource sharing fix.** Sharing resources with groups now works correctly, fixing database errors and an issue where models shared with read-only access were not visible to group members. [#20666](https://github.com/open-webui/open-webui/issues/20666), [#21043](https://github.com/open-webui/open-webui/issues/21043), [Commit](https://github.com/open-webui/open-webui/commit/5a075a2c836e46b83f8710285f09aff1f6125072)
|
||||
- 🔑 **Docling API key fix.** Docling API key authentication now works correctly by using the proper "X-Api-Key" header format instead of the incorrect "Bearer" authorization prefix. [#20652](https://github.com/open-webui/open-webui/pull/20652)
|
||||
- 🔌 **MCP OAuth 2.1 fix.** MCP OAuth 2.1 authentication now works correctly, resolving connection verification failures and 401 errors during the authorization callback. [#20808](https://github.com/open-webui/open-webui/issues/20808), [#20828](https://github.com/open-webui/open-webui/issues/20828), [Commit](https://github.com/open-webui/open-webui/commit/8eebc2aea63b7045e61c9689a65a2dfa9c797bcb)
|
||||
- 💻 **MATLAB syntax highlighting.** MATLAB code blocks now display with proper syntax highlighting in chat messages. [#20719](https://github.com/open-webui/open-webui/issues/20719), [#20773](https://github.com/open-webui/open-webui/pull/20773)
|
||||
- 📊 **CSV export HTML entity decoding.** Exporting tables to CSV now properly decodes HTML entities, ensuring special characters display correctly in the exported file. [#20688](https://github.com/open-webui/open-webui/pull/20688)
|
||||
- 📄 **Markdown Header Text Splitter persistence.** The "Markdown Header Text Splitter" document setting now persists correctly when disabled, preventing it from reverting to enabled after page refresh. [#20929](https://github.com/open-webui/open-webui/issues/20929), [#20930](https://github.com/open-webui/open-webui/pull/20930)
|
||||
- 🔌 **Audio service timeout handling.** Audio transcription and text-to-speech requests now have proper timeouts, preventing the UI from freezing when external services don't respond. [#21055](https://github.com/open-webui/open-webui/pull/21055)
|
||||
- 💬 **Reference Chats visibility fix.** The "Reference Chats" option now appears in the message input menu even when the sidebar is collapsed, fixing the issue where it was hidden on mobile devices and at first load. [#20827](https://github.com/open-webui/open-webui/issues/20827), [Commit](https://github.com/open-webui/open-webui/commit/a3600e8b219fc4c019b95258d16bd3e2827490c6)
|
||||
- 🔍 **Chat search self-exclusion.** The "search_chats" builtin tool now excludes the current conversation from search results, preventing redundant matches. [#20718](https://github.com/open-webui/open-webui/issues/20718), [Commit](https://github.com/open-webui/open-webui/commit/1a4bdd2b30017d901b9cac1e2e10684ec1edd062)
|
||||
- 📚 **Knowledge base pagination fix.** Paginating through knowledge base files no longer shows duplicates or skips files when multiple documents share the same update timestamp. [#20846](https://github.com/open-webui/open-webui/issues/20846), [Commit](https://github.com/open-webui/open-webui/commit/a9a0ce6beaa286cc18eff24b518a6f3d7a560e2f)
|
||||
- 📋 **Batch file error reporting.** Batch file processing operations now return properly structured error information when failures occur, making it clearer what went wrong during multi-file operations. [#20795](https://github.com/open-webui/open-webui/issues/20795), [Commit](https://github.com/open-webui/open-webui/commit/68b2872ed645cffb641fa5a21a784d6e9ea0d72b)
|
||||
- ⚙️ **Persistent config with Redis fix.** Configuration values now respect the "ENABLE_PERSISTENT_CONFIG" setting when Redis is used, ensuring environment variables are reloaded on restart when persistent config is disabled. [#20830](https://github.com/open-webui/open-webui/issues/20830), [Commit](https://github.com/open-webui/open-webui/commit/5d48e48e15b003874cc821d896998a01e87580a0)
|
||||
- 🔧 **Engine.IO logging fix.** The "WEBSOCKET_SERVER_ENGINEIO_LOGGING" environment variable now works correctly, allowing administrators to configure Engine.IO logging independently from general websocket logging. [#20727](https://github.com/open-webui/open-webui/pull/20727), [Commit](https://github.com/open-webui/open-webui/commit/5cfb7a08cbde5d39aaf4097b849a80da87c30d66)
|
||||
- 🌐 **French language default fix.** Browsers requesting French language now default to French (France) instead of French (Canada), matching standard language preference expectations. [#20603](https://github.com/open-webui/open-webui/pull/20603), [Commit](https://github.com/open-webui/open-webui/commit/4d9a7cc6c0adea54b58046c576250a0c3ae7b512)
|
||||
- 🔘 **Firefox delete button fix.** Pressing Enter after clicking delete buttons no longer incorrectly retriggers confirmation modals in Firefox. [Commit](https://github.com/open-webui/open-webui/commit/57a2024c58b9c674f2ae08eeb552994ef1796888)
|
||||
- 🌍 **RTL table rendering fix.** Chat markdown tables now correctly display right-to-left when containing RTL language content (Arabic, Hebrew, Farsi, etc.), matching the "Auto" direction setting behavior. [#21160](https://github.com/open-webui/open-webui/issues/21160), [Commit](https://github.com/open-webui/open-webui/commit/284b97bd84c824013ad00ea07621192ec69a5e93)
|
||||
- 🔒 **Write permission enforcement for tools.** Users without write permissions are now properly prevented from editing tools, with a clear error message displayed when attempting unauthorized edits. [Commit](https://github.com/open-webui/open-webui/commit/85e92fe3b062ae669985c09495f6ff1baf8176ab), [Commit](https://github.com/open-webui/open-webui/commit/91faa9fd5a1cfc5d3ab531d2d91d28db52bcc702)
|
||||
- 🛡️ **Chat Valves permission enforcement.** The "Allow Chat Valves" permission is now properly enforced in the integrations menu, preventing users from bypassing access restrictions. [#20691](https://github.com/open-webui/open-webui/pull/20691)
|
||||
- 📝 **Audit log browser session fix.** Audit logs now properly capture all user activity including browser-based sessions, not just API key requests. [#20651](https://github.com/open-webui/open-webui/issues/20651), [Commit](https://github.com/open-webui/open-webui/commit/86e6b2b68b85e958188881785495030de1a30402), [Commit](https://github.com/open-webui/open-webui/commit/ee5fd1246cb3f8f16ca5cbb24feeea43b7800dcb)
|
||||
- 🎨 **Long model name truncation.** Long model names and IDs in the admin panel now truncate properly to prevent visual overflow, with full names visible on hover. [#20696](https://github.com/open-webui/open-webui/pull/20696)
|
||||
- 👥 **Admin user filter pagination fix.** Filtering users in the admin panel now automatically resets to page 1, preventing empty results when searching from pages beyond the first. [#20723](https://github.com/open-webui/open-webui/pull/20723), [Commit](https://github.com/open-webui/open-webui/commit/be75bc506adb048ef11b1612c0e3662511c920d0)
|
||||
- 🔎 **Username search on workspace pages.** Searching for users by username now works correctly on Models, Knowledge, and Functions workspace pages, making it easier to find resources owned by specific users. [#20780](https://github.com/open-webui/open-webui/pull/20780)
|
||||
- 🗑️ **File deletion orphaned embeddings fix.** Deleting files now properly removes associated knowledge base embeddings, preventing orphaned data from accumulating. [Commit](https://github.com/open-webui/open-webui/commit/93ed4ae2cda2f4311143e51f586aaa73b83a37a7)
|
||||
- 🧹 **Event listener memory leak fix.** Memory leaks caused by event listeners not being cleaned up during navigation have been resolved. [#20913](https://github.com/open-webui/open-webui/pull/20913)
|
||||
- 🐳 **Docker Ollama update fix.** Ollama can now be updated within Docker containers after adding a missing zstd dependency. [#20994](https://github.com/open-webui/open-webui/issues/20994), [#21052](https://github.com/open-webui/open-webui/pull/21052)
|
||||
- 📝 **Workspace duplicate API request fix.** The prompts, knowledge, and models workspaces no longer make duplicate API requests when loading. [Commit](https://github.com/open-webui/open-webui/commit/ab5dfbda54664c9278b0d807ba06cad94edd798f), [Commit](https://github.com/open-webui/open-webui/commit/e5dbfc420dd3e7f6ba047a3e11584449ff0742b4)
|
||||
- 📡 **OpenTelemetry Redis cluster fix.** OpenTelemetry instrumentation now works correctly with Redis cluster mode deployments. [#21129](https://github.com/open-webui/open-webui/pull/21129)
|
||||
- 🐳 **Airgapped NLTK tokenizer fix.** Document extraction now works reliably in airgapped environments after container restarts by bundling NLTK tokenizer data in the Docker image. [#21165](https://github.com/open-webui/open-webui/pull/21165), [#21150](https://github.com/open-webui/open-webui/issues/21150)
|
||||
- 💬 **Channel model mention crash fix.** Mentioning a model in channels no longer crashes when older thread messages have missing data. [#21112](https://github.com/open-webui/open-webui/pull/21112)
|
||||
- 🔧 **OpenAPI tool import fix.** Importing OpenAPI tool specifications no longer crashes when parameters lack explicit name fields, fixing compatibility with complex request body definitions. [#21121](https://github.com/open-webui/open-webui/pull/21121), [Commit](https://github.com/open-webui/open-webui/commit/8e79b3d0bc4903f30e747b663ac818976618c83c)
|
||||
- 🌐 **Webpage attachment content fix.** Attaching webpages to chats now retrieves full content instead of only metadata, fixing an unawaited coroutine in SSL certificate verification. [#21166](https://github.com/open-webui/open-webui/issues/21166), [Commit](https://github.com/open-webui/open-webui/commit/a214ec40ea00eebcba49570647ca6ab8f61765d5)
|
||||
- 💾 **File upload settings persistence.** File upload settings (Max Upload Size, Max File Count, Image Compression dimensions) now persist correctly and are no longer erased when updating other RAG configuration settings. [#21057](https://github.com/open-webui/open-webui/issues/21057), [Commit](https://github.com/open-webui/open-webui/commit/258454276e1ef8ded24968515f7bf5e1833ca011)
|
||||
- 📦 **Tool call expand/collapse fix.** Tool call results in chat can now be expanded and collapsed again after a recent refactor disabled this behavior. [#21205](https://github.com/open-webui/open-webui/pull/21205)
|
||||
- 🪛 **Disabled API endpoint bypass fix.** Fixed Ollama/OpenAI API endpoints bypassing 'ENABLE_OLLAMA_API' and 'ENABLE_OPENAI_API' flags when the 'url_idx' parameter was provided. Endpoints now properly return a 503 error with a clear "API is disabled" message instead of attempting to connect and logging confusing connection errors.
|
||||
- 🛠️ **OpenSearch 3.0 compatibility fix.** Document uploads to knowledge bases now work correctly when using OpenSearch backend with opensearch-py >= 3.0.0, fixing a TypeError that previously caused failures. [#21248](https://github.com/open-webui/open-webui/pull/21248), [#20649](https://github.com/open-webui/open-webui/issues/20649)
|
||||
- 📱 **Gboard multi-line paste fix.** Multi-line text pasted from Gboard on Android now inserts correctly instead of being replaced with a single newline, fixing a bug where the keyboard's clipboard suggestion strip sent text via 'insertText' events instead of standard paste events. [#21265](https://github.com/open-webui/open-webui/pull/21265)
|
||||
- 🔧 **Batch embeddings endpoint fix.** The '/api/embeddings' endpoint now correctly returns separate embeddings for each input string when processing batch requests to Ollama providers. [Commit](https://github.com/open-webui/open-webui/commit/8fd5c06e5bf7e0ccbda15d83338912ea17f66783), [#21279](https://github.com/open-webui/open-webui/issues/21279)
|
||||
- 🗝️ **SSL verification for embeddings.** SSL certificate verification now respects the "AIOHTTP_CLIENT_SESSION_SSL" setting for OpenAI and Azure OpenAI embedding requests, allowing connections to self-signed certificate endpoints when disabled. [Commit](https://github.com/open-webui/open-webui/commit/cd31b8301b38bfa86872608cfbd022ff74e3ae52)
|
||||
- 🔧 **Tool call HTML entity fix.** Models now receive properly formatted tool call results in multi-turn conversations, fixing an issue where HTML entities caused malformed content that was hard to parse. [#20755](https://github.com/open-webui/open-webui/pull/20755)
|
||||
- 💾 **Duplicate inline image context fix.** Inline images no longer exhaust the model's context window by including their full base64 data in chat metadata, preventing premature context exhaustion with image-heavy conversations. [#20916](https://github.com/open-webui/open-webui/pull/20916)
|
||||
- 🐛 **OpenAI model cache lookup fix.** The OpenAI API router model lookup was corrected to use the proper model identifier when checking the cache, ensuring consistent and correct model retrieval during chat completions. [#21327](https://github.com/open-webui/open-webui/pull/21327)
|
||||
- 🐛 **Ollama latest suffix fix.** Ollama-compatible providers that don't use ":latest" in model names can now successfully chat, fixing errors where model names were incorrectly appended with ":latest" suffixes. [#21331](https://github.com/open-webui/open-webui/issues/21331), [Commit](https://github.com/open-webui/open-webui/commit/05ae44b98dc279ee12cc8eab17278ccbfec60301)
|
||||
- ⛔ **OpenAI endpoint detection fix.** OpenAI API endpoint detection was corrected to use exact hostname matching instead of substring matching, preventing third-party providers with similar URL patterns from being incorrectly filtered. [Commit](https://github.com/open-webui/open-webui/commit/423d8b18170a0b92b582aba6ef7bb9ba173e876e)
|
||||
- 🛠️ **RedisCluster task stopping fix.** Task stopping now works correctly in RedisCluster deployments, fixing an issue where tasks would remain active after cancellation attempts. [#20803](https://github.com/open-webui/open-webui/pull/20803), [Commit](https://github.com/open-webui/open-webui/commit/0dcbd05e2436929ae9d2c559a204844ae0239b57)
|
||||
- 📎 **Citation parsing error fix.** Citation parsing no longer crashes when builtin tools return error responses, fixing AttributeError issues when tools like search_web fail. [#21071](https://github.com/open-webui/open-webui/pull/21071)
|
||||
|
||||
### Changed
|
||||
|
||||
- ‼️ **Database Migration Required** — This release includes database schema changes; multi-worker, multi-server, or load-balanced deployments must update all instances simultaneously rather than performing rolling updates, as running mixed versions will cause application failures due to schema incompatibility between old and new instances.
|
||||
- ⚠️ **Chat Message Table Migration** — This release includes a new chat message table migration that can take a significant amount of time to complete in larger deployments with extensive chat histories. Administrators should plan for adequate maintenance windows and allow the migration to complete fully without interruption. Running the migration with insufficient time or resources may result in data integrity issues.
|
||||
- 🔗 **Prompt ID-based URLs.** Prompts now use unique ID-based URLs instead of command-based URLs, allowing more flexible command renaming without breaking saved links or integrations. [#20945](https://github.com/open-webui/open-webui/pull/20945)
|
||||
|
||||
## [0.7.2] - 2026-01-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚡ Users no longer experience database connection timeouts under high concurrency due to connections being held during LLM calls, telemetry collection, and file status streaming. [#20545](https://github.com/open-webui/open-webui/pull/20545), [#20542](https://github.com/open-webui/open-webui/pull/20542), [#20547](https://github.com/open-webui/open-webui/pull/20547)
|
||||
- 📝 Users can now create and save prompts in the workspace prompts editor without encountering errors. [Commit](https://github.com/open-webui/open-webui/commit/ab99d3b1129cffbc13cf7de5aa897692e3f8662e)
|
||||
- 🎙️ Users can now use local Whisper for speech-to-text when STT_ENGINE is left empty (the default for local mode). [#20534](https://github.com/open-webui/open-webui/pull/20534)
|
||||
- 📊 The Evaluations page now loads faster by eliminating duplicate API calls to the leaderboard and feedbacks endpoints. [Commit](https://github.com/open-webui/open-webui/commit/2dd09223f2aac301a4d5c17fb667d974c34f3ff1)
|
||||
- 🌐 Fixed missing Settings tab i18n label keys. [#20526](https://github.com/open-webui/open-webui/pull/20526)
|
||||
|
||||
## [0.7.1] - 2026-01-09
|
||||
|
||||
### Fixed
|
||||
|
||||
+6
-3
@@ -128,7 +128,7 @@ RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git build-essential pandoc gcc netcat-openbsd curl jq \
|
||||
python3-dev \
|
||||
ffmpeg libsm6 libxext6 \
|
||||
ffmpeg libsm6 libxext6 zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install python dependencies
|
||||
@@ -137,20 +137,23 @@ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
||||
RUN pip3 install --no-cache-dir uv && \
|
||||
if [ "$USE_CUDA" = "true" ]; then \
|
||||
# If you use CUDA the whisper and embedding model will be downloaded on first use
|
||||
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
|
||||
# fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349
|
||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir && \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
|
||||
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
||||
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||
else \
|
||||
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
|
||||
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
|
||||
uv pip install --system -r requirements.txt --no-cache-dir && \
|
||||
if [ "$USE_SLIM" != "true" ]; then \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
|
||||
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
|
||||
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
|
||||
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
|
||||
python -c "import nltk; nltk.download('punkt_tab')"; \
|
||||
fi; \
|
||||
fi; \
|
||||
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
|
||||
|
||||
@@ -2,7 +2,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import base64
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import redis
|
||||
|
||||
from datetime import datetime
|
||||
@@ -257,7 +259,7 @@ class AppConfig:
|
||||
self._state[key].value = value
|
||||
self._state[key].save()
|
||||
|
||||
if self._redis:
|
||||
if self._redis and ENABLE_PERSISTENT_CONFIG:
|
||||
redis_key = f"{self._redis_key_prefix}:config:{key}"
|
||||
self._redis.set(redis_key, json.dumps(self._state[key].value))
|
||||
|
||||
@@ -265,8 +267,8 @@ class AppConfig:
|
||||
if key not in self._state:
|
||||
raise AttributeError(f"Config key '{key}' not found")
|
||||
|
||||
# If Redis is available, check for an updated value
|
||||
if self._redis:
|
||||
# If Redis is available and persistent config is enabled, check for an updated value
|
||||
if self._redis and ENABLE_PERSISTENT_CONFIG:
|
||||
redis_key = f"{self._redis_key_prefix}:config:{key}"
|
||||
redis_value = self._redis.get(redis_key)
|
||||
|
||||
@@ -1015,6 +1017,39 @@ if ENV == "prod":
|
||||
OLLAMA_BASE_URL = "http://ollama-service.open-webui.svc.cluster.local:11434"
|
||||
|
||||
|
||||
def _resolve_ollama_base_url(url: str) -> str:
|
||||
"""If the default Ollama port (11434) is unreachable, try the fallback port (12434)."""
|
||||
|
||||
def reachable(host: str, port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=1.0):
|
||||
return True
|
||||
except (OSError, TimeoutError):
|
||||
return False
|
||||
|
||||
host = urlparse(url).hostname or "localhost"
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
default = pool.submit(reachable, host, 11434)
|
||||
fallback = pool.submit(reachable, host, 12434)
|
||||
|
||||
if not default.result() and fallback.result():
|
||||
url = url.replace(":11434", ":12434")
|
||||
log.info(f"Ollama port 11434 unreachable on {host}, falling back to 12434")
|
||||
elif not default.result():
|
||||
log.info(f"Ollama ports 11434 and 12434 both unreachable on {host}")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
# Auto-resolve Ollama port when no explicit URL was provided by the user.
|
||||
# The Dockerfile default is "/ollama" which the block above rewrites to :11434.
|
||||
if os.environ.get("OLLAMA_BASE_URL", "") in ("", "/ollama") and not os.environ.get(
|
||||
"OLLAMA_BASE_URLS", ""
|
||||
):
|
||||
OLLAMA_BASE_URL = _resolve_ollama_base_url(OLLAMA_BASE_URL)
|
||||
|
||||
|
||||
OLLAMA_BASE_URLS = os.environ.get("OLLAMA_BASE_URLS", "")
|
||||
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != "" else OLLAMA_BASE_URL
|
||||
|
||||
@@ -1265,6 +1300,11 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
|
||||
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS = (
|
||||
os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS", "False").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT = (
|
||||
os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT", "False").lower()
|
||||
== "true"
|
||||
@@ -1345,6 +1385,18 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING = (
|
||||
os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING", "False").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING = (
|
||||
os.environ.get(
|
||||
"USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING", "False"
|
||||
).lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
|
||||
USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
|
||||
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower() == "true"
|
||||
@@ -1486,6 +1538,7 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
|
||||
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
|
||||
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
|
||||
"skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS,
|
||||
"models_import": USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT,
|
||||
"models_export": USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT,
|
||||
"prompts_import": USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT,
|
||||
@@ -1502,6 +1555,8 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
|
||||
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING,
|
||||
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
|
||||
"skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING,
|
||||
"public_skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING,
|
||||
"notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
|
||||
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
||||
},
|
||||
@@ -2246,9 +2301,15 @@ ENABLE_QDRANT_MULTITENANCY_MODE = (
|
||||
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
|
||||
|
||||
WEAVIATE_HTTP_HOST = os.environ.get("WEAVIATE_HTTP_HOST", "")
|
||||
WEAVIATE_GRPC_HOST = os.environ.get("WEAVIATE_GRPC_HOST", "")
|
||||
WEAVIATE_HTTP_PORT = int(os.environ.get("WEAVIATE_HTTP_PORT", "8080"))
|
||||
WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051"))
|
||||
WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY")
|
||||
WEAVIATE_HTTP_SECURE = os.environ.get("WEAVIATE_HTTP_SECURE", "false").lower() == "true"
|
||||
WEAVIATE_GRPC_SECURE = os.environ.get("WEAVIATE_GRPC_SECURE", "false").lower() == "true"
|
||||
WEAVIATE_SKIP_INIT_CHECKS = (
|
||||
os.environ.get("WEAVIATE_SKIP_INIT_CHECKS", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# OpenSearch
|
||||
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
|
||||
@@ -2805,6 +2866,12 @@ PDF_EXTRACT_IMAGES = PersistentConfig(
|
||||
os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true",
|
||||
)
|
||||
|
||||
PDF_LOADER_MODE = PersistentConfig(
|
||||
"PDF_LOADER_MODE",
|
||||
"rag.pdf_loader_mode",
|
||||
os.environ.get("PDF_LOADER_MODE", "page"),
|
||||
)
|
||||
|
||||
RAG_EMBEDDING_MODEL = PersistentConfig(
|
||||
"RAG_EMBEDDING_MODEL",
|
||||
"rag.embedding_model",
|
||||
@@ -3400,6 +3467,24 @@ EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig(
|
||||
os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""),
|
||||
)
|
||||
|
||||
YANDEX_WEB_SEARCH_URL = PersistentConfig(
|
||||
"YANDEX_WEB_SEARCH_URL",
|
||||
"rag.web.search.yandex_web_search_url",
|
||||
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
|
||||
)
|
||||
|
||||
YANDEX_WEB_SEARCH_API_KEY = PersistentConfig(
|
||||
"YANDEX_WEB_SEARCH_API_KEY",
|
||||
"rag.web.search.yandex_web_search_api_key",
|
||||
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
|
||||
)
|
||||
|
||||
YANDEX_WEB_SEARCH_CONFIG = PersistentConfig(
|
||||
"YANDEX_WEB_SEARCH_CONFIG",
|
||||
"rag.web.search.yandex_web_search_config",
|
||||
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""),
|
||||
)
|
||||
|
||||
####################################
|
||||
# Images
|
||||
####################################
|
||||
@@ -3422,6 +3507,16 @@ IMAGE_GENERATION_MODEL = PersistentConfig(
|
||||
os.getenv("IMAGE_GENERATION_MODEL", ""),
|
||||
)
|
||||
|
||||
# Regex pattern for models that support IMAGE_SIZE = "auto".
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv(
|
||||
"IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN", "^gpt-image"
|
||||
)
|
||||
|
||||
# Regex pattern for models that return URLs instead of base64 data.
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv(
|
||||
"IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN", "^gpt-image"
|
||||
)
|
||||
|
||||
IMAGE_SIZE = PersistentConfig(
|
||||
"IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512")
|
||||
)
|
||||
|
||||
@@ -194,11 +194,35 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
|
||||
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
|
||||
)
|
||||
|
||||
# Header names for user info forwarding (customizable via environment variables)
|
||||
FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get(
|
||||
"FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name"
|
||||
)
|
||||
FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get(
|
||||
"FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id"
|
||||
)
|
||||
FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get(
|
||||
"FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email"
|
||||
)
|
||||
FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get(
|
||||
"FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role"
|
||||
)
|
||||
|
||||
# Header name for chat ID forwarding (customizable via environment variable)
|
||||
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID = os.environ.get(
|
||||
"FORWARD_SESSION_INFO_HEADER_MESSAGE_ID", "X-OpenWebUI-Message-Id"
|
||||
)
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get(
|
||||
"FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id"
|
||||
)
|
||||
|
||||
# Experimental feature, may be removed in future
|
||||
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
|
||||
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
|
||||
)
|
||||
|
||||
ENABLE_EASTER_EGGS = os.environ.get("ENABLE_EASTER_EGGS", "True").lower() == "true"
|
||||
|
||||
####################################
|
||||
# WEBUI_BUILD_HASH
|
||||
####################################
|
||||
@@ -391,6 +415,18 @@ try:
|
||||
except ValueError:
|
||||
REDIS_SOCKET_CONNECT_TIMEOUT = None
|
||||
|
||||
REDIS_RECONNECT_DELAY = os.environ.get("REDIS_RECONNECT_DELAY", "")
|
||||
|
||||
if REDIS_RECONNECT_DELAY == "":
|
||||
REDIS_RECONNECT_DELAY = None
|
||||
else:
|
||||
try:
|
||||
REDIS_RECONNECT_DELAY = float(REDIS_RECONNECT_DELAY)
|
||||
if REDIS_RECONNECT_DELAY < 0:
|
||||
REDIS_RECONNECT_DELAY = None
|
||||
except Exception:
|
||||
REDIS_RECONNECT_DELAY = None
|
||||
|
||||
####################################
|
||||
# UVICORN WORKERS
|
||||
####################################
|
||||
@@ -442,7 +478,7 @@ ENABLE_PASSWORD_VALIDATION = (
|
||||
)
|
||||
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
|
||||
"PASSWORD_VALIDATION_REGEX_PATTERN",
|
||||
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
|
||||
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
|
||||
)
|
||||
|
||||
|
||||
@@ -455,6 +491,8 @@ except Exception as e:
|
||||
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
|
||||
)
|
||||
|
||||
PASSWORD_VALIDATION_HINT = os.environ.get("PASSWORD_VALIDATION_HINT", "")
|
||||
|
||||
|
||||
BYPASS_MODEL_ACCESS_CONTROL = (
|
||||
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
|
||||
@@ -519,6 +557,12 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
# Token Exchange Configuration
|
||||
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
|
||||
ENABLE_OAUTH_TOKEN_EXCHANGE = (
|
||||
os.environ.get("ENABLE_OAUTH_TOKEN_EXCHANGE", "False").lower() == "true"
|
||||
)
|
||||
|
||||
####################################
|
||||
# SCIM Configuration
|
||||
####################################
|
||||
@@ -528,6 +572,14 @@ ENABLE_SCIM = (
|
||||
== "true"
|
||||
)
|
||||
SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "")
|
||||
SCIM_AUTH_PROVIDER = os.environ.get("SCIM_AUTH_PROVIDER", "")
|
||||
|
||||
if ENABLE_SCIM and not SCIM_AUTH_PROVIDER:
|
||||
log.warning(
|
||||
"SCIM is enabled but SCIM_AUTH_PROVIDER is not set. "
|
||||
"Set SCIM_AUTH_PROVIDER to the OAuth provider name (e.g. 'microsoft', 'oidc') "
|
||||
"to enable externalId storage."
|
||||
)
|
||||
|
||||
####################################
|
||||
# LICENSE_KEY
|
||||
@@ -545,15 +597,11 @@ LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "")
|
||||
|
||||
pk = None
|
||||
if LICENSE_PUBLIC_KEY:
|
||||
pk = serialization.load_pem_public_key(
|
||||
f"""
|
||||
pk = serialization.load_pem_public_key(f"""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
{LICENSE_PUBLIC_KEY}
|
||||
-----END PUBLIC KEY-----
|
||||
""".encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
""".encode("utf-8"))
|
||||
|
||||
|
||||
####################################
|
||||
@@ -673,7 +721,11 @@ WEBSOCKET_SERVER_LOGGING = (
|
||||
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
|
||||
)
|
||||
WEBSOCKET_SERVER_ENGINEIO_LOGGING = (
|
||||
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
|
||||
os.environ.get(
|
||||
"WEBSOCKET_SERVER_ENGINEIO_LOGGING",
|
||||
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False"),
|
||||
).lower()
|
||||
== "true"
|
||||
)
|
||||
WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get("WEBSOCKET_SERVER_PING_TIMEOUT", "20")
|
||||
try:
|
||||
@@ -739,6 +791,17 @@ AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
|
||||
)
|
||||
|
||||
|
||||
RAG_EMBEDDING_TIMEOUT = os.environ.get("RAG_EMBEDDING_TIMEOUT", "")
|
||||
|
||||
if RAG_EMBEDDING_TIMEOUT == "":
|
||||
RAG_EMBEDDING_TIMEOUT = None
|
||||
else:
|
||||
try:
|
||||
RAG_EMBEDDING_TIMEOUT = int(RAG_EMBEDDING_TIMEOUT)
|
||||
except Exception:
|
||||
RAG_EMBEDDING_TIMEOUT = None
|
||||
|
||||
|
||||
####################################
|
||||
# SENTENCE TRANSFORMERS
|
||||
####################################
|
||||
|
||||
@@ -35,7 +35,6 @@ from open_webui.utils.plugin import (
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.tools import get_tools
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
|
||||
@@ -51,7 +50,6 @@ from open_webui.utils.payload import (
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from contextlib import suppress
|
||||
import peewee as pw
|
||||
from peewee_migrate import Migrator
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
|
||||
|
||||
+112
-18
@@ -64,10 +64,12 @@ from open_webui.socket.main import (
|
||||
MODELS,
|
||||
app as socket_app,
|
||||
periodic_usage_pool_cleanup,
|
||||
periodic_session_pool_cleanup,
|
||||
get_event_emitter,
|
||||
get_models_in_use,
|
||||
)
|
||||
from open_webui.routers import (
|
||||
analytics,
|
||||
audio,
|
||||
images,
|
||||
ollama,
|
||||
@@ -89,6 +91,7 @@ from open_webui.routers import (
|
||||
knowledge,
|
||||
prompts,
|
||||
evaluations,
|
||||
skills,
|
||||
tools,
|
||||
users,
|
||||
utils,
|
||||
@@ -288,6 +291,7 @@ from open_webui.config import (
|
||||
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
|
||||
TIKTOKEN_ENCODING_NAME,
|
||||
PDF_EXTRACT_IMAGES,
|
||||
PDF_LOADER_MODE,
|
||||
YOUTUBE_LOADER_LANGUAGE,
|
||||
YOUTUBE_LOADER_PROXY_URL,
|
||||
# Retrieval (Web Search)
|
||||
@@ -352,6 +356,9 @@ from open_webui.config import (
|
||||
EXTERNAL_WEB_SEARCH_API_KEY,
|
||||
EXTERNAL_WEB_LOADER_URL,
|
||||
EXTERNAL_WEB_LOADER_API_KEY,
|
||||
YANDEX_WEB_SEARCH_URL,
|
||||
YANDEX_WEB_SEARCH_API_KEY,
|
||||
YANDEX_WEB_SEARCH_CONFIG,
|
||||
# WebUI
|
||||
WEBUI_AUTH,
|
||||
WEBUI_NAME,
|
||||
@@ -490,6 +497,7 @@ from open_webui.env import (
|
||||
WEBUI_ADMIN_EMAIL,
|
||||
WEBUI_ADMIN_PASSWORD,
|
||||
WEBUI_ADMIN_NAME,
|
||||
ENABLE_EASTER_EGGS,
|
||||
)
|
||||
|
||||
|
||||
@@ -502,11 +510,15 @@ from open_webui.utils.models import (
|
||||
from open_webui.utils.chat import (
|
||||
generate_chat_completion as chat_completion_handler,
|
||||
chat_completed as chat_completed_handler,
|
||||
chat_action as chat_action_handler,
|
||||
)
|
||||
from open_webui.utils.actions import chat_action as chat_action_handler
|
||||
from open_webui.utils.embeddings import generate_embeddings
|
||||
from open_webui.utils.middleware import process_chat_payload, process_chat_response
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.middleware import (
|
||||
build_chat_response_context,
|
||||
process_chat_payload,
|
||||
process_chat_response,
|
||||
)
|
||||
from open_webui.utils.tools import set_tool_servers
|
||||
|
||||
from open_webui.utils.auth import (
|
||||
get_license_data,
|
||||
@@ -541,7 +553,6 @@ from open_webui.utils.redis import get_sentinels_from_env
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
|
||||
if SAFE_MODE:
|
||||
print("SAFE MODE ENABLED")
|
||||
Functions.deactivate_all_functions()
|
||||
@@ -565,8 +576,7 @@ class SPAStaticFiles(StaticFiles):
|
||||
raise ex
|
||||
|
||||
|
||||
print(
|
||||
rf"""
|
||||
print(rf"""
|
||||
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
|
||||
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
|
||||
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
|
||||
@@ -578,12 +588,15 @@ print(
|
||||
v{VERSION} - building the best AI user interface.
|
||||
{f"Commit: {WEBUI_BUILD_HASH}" if WEBUI_BUILD_HASH != "dev-build" else ""}
|
||||
https://github.com/open-webui/open-webui
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Store reference to main event loop for sync->async calls (e.g., embedding generation)
|
||||
# This allows sync functions to schedule work on the main loop without blocking health checks
|
||||
app.state.main_loop = asyncio.get_running_loop()
|
||||
|
||||
app.state.instance_id = INSTANCE_ID
|
||||
start_logger()
|
||||
|
||||
@@ -623,11 +636,37 @@ async def lifespan(app: FastAPI):
|
||||
limiter.total_tokens = THREAD_POOL_SIZE
|
||||
|
||||
asyncio.create_task(periodic_usage_pool_cleanup())
|
||||
asyncio.create_task(periodic_session_pool_cleanup())
|
||||
|
||||
if app.state.config.ENABLE_BASE_MODELS_CACHE:
|
||||
await get_all_models(
|
||||
Request(
|
||||
# Creating a mock request object to pass to get_all_models
|
||||
try:
|
||||
await get_all_models(
|
||||
Request(
|
||||
# Creating a mock request object to pass to get_all_models
|
||||
{
|
||||
"type": "http",
|
||||
"asgi.version": "3.0",
|
||||
"asgi.spec_version": "2.0",
|
||||
"method": "GET",
|
||||
"path": "/internal",
|
||||
"query_string": b"",
|
||||
"headers": Headers({}).raw,
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("127.0.0.1", 80),
|
||||
"scheme": "http",
|
||||
"app": app,
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to pre-fetch models at startup: {e}")
|
||||
|
||||
# Pre-fetch tool server specs so the first request doesn't pay the latency cost
|
||||
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
|
||||
log.info("Initializing tool servers...")
|
||||
try:
|
||||
mock_request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"asgi.version": "3.0",
|
||||
@@ -641,9 +680,11 @@ async def lifespan(app: FastAPI):
|
||||
"scheme": "http",
|
||||
"app": app,
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
)
|
||||
await set_tool_servers(mock_request)
|
||||
log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to initialize tool servers at startup: {e}")
|
||||
|
||||
yield
|
||||
|
||||
@@ -806,6 +847,21 @@ app.state.config.ENABLE_USER_STATUS = ENABLE_USER_STATUS
|
||||
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
|
||||
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
|
||||
|
||||
# Migrate legacy access_control → access_grants on boot
|
||||
from open_webui.utils.access_control import migrate_access_control
|
||||
|
||||
connections = app.state.config.TOOL_SERVER_CONNECTIONS
|
||||
if any("access_control" in c.get("config", {}) for c in connections):
|
||||
for connection in connections:
|
||||
migrate_access_control(connection.get("config", {}))
|
||||
app.state.config.TOOL_SERVER_CONNECTIONS = connections
|
||||
|
||||
arena_models = app.state.config.EVALUATION_ARENA_MODELS
|
||||
if any("access_control" in m.get("meta", {}) for m in arena_models):
|
||||
for model in arena_models:
|
||||
migrate_access_control(model.get("meta", {}))
|
||||
app.state.config.EVALUATION_ARENA_MODELS = arena_models
|
||||
|
||||
app.state.config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
|
||||
app.state.config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
|
||||
app.state.config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
|
||||
@@ -944,6 +1000,7 @@ app.state.config.RAG_OLLAMA_BASE_URL = RAG_OLLAMA_BASE_URL
|
||||
app.state.config.RAG_OLLAMA_API_KEY = RAG_OLLAMA_API_KEY
|
||||
|
||||
app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES
|
||||
app.state.config.PDF_LOADER_MODE = PDF_LOADER_MODE
|
||||
|
||||
app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE
|
||||
app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL
|
||||
@@ -1005,6 +1062,9 @@ app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL
|
||||
app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY
|
||||
app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL
|
||||
app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
|
||||
app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL
|
||||
app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY
|
||||
app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG
|
||||
|
||||
|
||||
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
|
||||
@@ -1315,7 +1375,9 @@ class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
|
||||
token = None
|
||||
|
||||
if auth_header:
|
||||
scheme, token = auth_header.split(" ")
|
||||
parts = auth_header.split(" ", 1)
|
||||
if len(parts) == 2:
|
||||
token = parts[1]
|
||||
|
||||
# Only apply restrictions if an sk- API key is used
|
||||
if token and token.startswith("sk-"):
|
||||
@@ -1356,7 +1418,13 @@ app.add_middleware(APIKeyRestrictionMiddleware)
|
||||
async def commit_session_after_request(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
# log.debug("Commit session after request")
|
||||
ScopedSession.commit()
|
||||
try:
|
||||
ScopedSession.commit()
|
||||
finally:
|
||||
# CRITICAL: remove() returns the connection to the pool.
|
||||
# Without this, connections remain "checked out" and accumulate
|
||||
# as "idle in transaction" in PostgreSQL.
|
||||
ScopedSession.remove()
|
||||
return response
|
||||
|
||||
|
||||
@@ -1366,6 +1434,13 @@ async def check_url(request: Request, call_next):
|
||||
request.state.token = get_http_authorization_cred(
|
||||
request.headers.get("Authorization")
|
||||
)
|
||||
# Fallback to cookie token for browser sessions
|
||||
if request.state.token is None and request.cookies.get("token"):
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
request.state.token = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer", credentials=request.cookies.get("token")
|
||||
)
|
||||
|
||||
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
|
||||
response = await call_next(request)
|
||||
@@ -1430,6 +1505,7 @@ app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
|
||||
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||
app.include_router(prompts.router, prefix="/api/v1/prompts", tags=["prompts"])
|
||||
app.include_router(tools.router, prefix="/api/v1/tools", tags=["tools"])
|
||||
app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"])
|
||||
|
||||
app.include_router(memories.router, prefix="/api/v1/memories", tags=["memories"])
|
||||
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
||||
@@ -1439,6 +1515,7 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function
|
||||
app.include_router(
|
||||
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
|
||||
)
|
||||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
||||
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
|
||||
|
||||
# SCIM 2.0 API for identity management
|
||||
@@ -1726,9 +1803,11 @@ async def chat_completion(
|
||||
except:
|
||||
pass
|
||||
|
||||
return await process_chat_response(
|
||||
request, response, form_data, user, metadata, model, events, tasks
|
||||
ctx = build_chat_response_context(
|
||||
request, form_data, user, model, metadata, tasks, events
|
||||
)
|
||||
|
||||
return await process_chat_response(response, ctx)
|
||||
except asyncio.CancelledError:
|
||||
log.info("Chat processing was cancelled")
|
||||
try:
|
||||
@@ -1778,6 +1857,16 @@ async def chat_completion(
|
||||
except Exception as e:
|
||||
log.debug(f"Error cleaning up: {e}")
|
||||
pass
|
||||
# Emit chat:active=false when task completes
|
||||
try:
|
||||
if metadata.get("chat_id"):
|
||||
event_emitter = get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter(
|
||||
{"type": "chat:active", "data": {"active": False}}
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Error emitting chat:active: {e}")
|
||||
|
||||
if (
|
||||
metadata.get("session_id")
|
||||
@@ -1790,6 +1879,10 @@ async def chat_completion(
|
||||
process_chat(request, form_data, user, metadata, model),
|
||||
id=metadata["chat_id"],
|
||||
)
|
||||
# Emit chat:active=true when task starts
|
||||
event_emitter = get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter({"type": "chat:active", "data": {"active": True}})
|
||||
return {"status": True, "task_id": task_id}
|
||||
else:
|
||||
return await process_chat(request, form_data, user, metadata, model)
|
||||
@@ -1930,6 +2023,7 @@ async def get_app_config(request: Request):
|
||||
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
|
||||
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
|
||||
"enable_public_active_users_count": ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
|
||||
"enable_easter_eggs": ENABLE_EASTER_EGGS,
|
||||
**(
|
||||
{
|
||||
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
|
||||
|
||||
-1
@@ -12,7 +12,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2f1211949ecc"
|
||||
down_revision: Union[str, None] = "37f288994c47"
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Add prompt history table
|
||||
|
||||
Revision ID: 374d2f66af06
|
||||
Revises: c440947495f3
|
||||
Create Date: 2026-01-23 17:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
import uuid
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "374d2f66af06"
|
||||
down_revision: Union[str, None] = "c440947495f3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Step 1: Read existing data from OLD table (schema likely command as PK)
|
||||
# We use batch_alter previously, but we want to move to new table.
|
||||
# We need to assume the OLD structure.
|
||||
|
||||
old_prompt_table = sa.table(
|
||||
"prompt",
|
||||
sa.column("command", sa.Text()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("title", sa.Text()),
|
||||
sa.column("content", sa.Text()),
|
||||
sa.column("timestamp", sa.BigInteger()),
|
||||
sa.column("access_control", sa.JSON()),
|
||||
)
|
||||
|
||||
# Check if table exists/read data
|
||||
try:
|
||||
existing_prompts = conn.execute(
|
||||
sa.select(
|
||||
old_prompt_table.c.command,
|
||||
old_prompt_table.c.user_id,
|
||||
old_prompt_table.c.title,
|
||||
old_prompt_table.c.content,
|
||||
old_prompt_table.c.timestamp,
|
||||
old_prompt_table.c.access_control,
|
||||
)
|
||||
).fetchall()
|
||||
except Exception:
|
||||
# Fallback if table doesn't exist (new install)
|
||||
existing_prompts = []
|
||||
|
||||
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
|
||||
op.create_table(
|
||||
"prompt_new",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("command", sa.String(), unique=True, index=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("access_control", sa.JSON(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("version_id", sa.Text(), nullable=True),
|
||||
sa.Column("tags", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 3: Create prompt_history table
|
||||
op.create_table(
|
||||
"prompt_history",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("prompt_id", sa.Text(), nullable=False, index=True),
|
||||
sa.Column("parent_id", sa.Text(), nullable=True),
|
||||
sa.Column("snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("commit_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
|
||||
# Step 4: Migrate data
|
||||
prompt_new_table = sa.table(
|
||||
"prompt_new",
|
||||
sa.column("id", sa.Text()),
|
||||
sa.column("command", sa.String()),
|
||||
sa.column("user_id", sa.String()),
|
||||
sa.column("name", sa.Text()),
|
||||
sa.column("content", sa.Text()),
|
||||
sa.column("data", sa.JSON()),
|
||||
sa.column("meta", sa.JSON()),
|
||||
sa.column("access_control", sa.JSON()),
|
||||
sa.column("is_active", sa.Boolean()),
|
||||
sa.column("version_id", sa.Text()),
|
||||
sa.column("tags", sa.JSON()),
|
||||
sa.column("created_at", sa.BigInteger()),
|
||||
sa.column("updated_at", sa.BigInteger()),
|
||||
)
|
||||
|
||||
prompt_history_table = sa.table(
|
||||
"prompt_history",
|
||||
sa.column("id", sa.Text()),
|
||||
sa.column("prompt_id", sa.Text()),
|
||||
sa.column("parent_id", sa.Text()),
|
||||
sa.column("snapshot", sa.JSON()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("commit_message", sa.Text()),
|
||||
sa.column("created_at", sa.BigInteger()),
|
||||
)
|
||||
|
||||
for row in existing_prompts:
|
||||
command = row[0]
|
||||
user_id = row[1]
|
||||
title = row[2]
|
||||
content = row[3]
|
||||
timestamp = row[4]
|
||||
access_control = row[5]
|
||||
|
||||
new_uuid = str(uuid.uuid4())
|
||||
history_uuid = str(uuid.uuid4())
|
||||
clean_command = command[1:] if command and command.startswith("/") else command
|
||||
|
||||
# Insert into prompt_new
|
||||
conn.execute(
|
||||
sa.insert(prompt_new_table).values(
|
||||
id=new_uuid,
|
||||
command=clean_command,
|
||||
user_id=user_id,
|
||||
name=title,
|
||||
content=content,
|
||||
data={},
|
||||
meta={},
|
||||
access_control=access_control,
|
||||
is_active=True,
|
||||
version_id=history_uuid,
|
||||
tags=[],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Create initial history entry
|
||||
conn.execute(
|
||||
sa.insert(prompt_history_table).values(
|
||||
id=history_uuid,
|
||||
prompt_id=new_uuid,
|
||||
parent_id=None,
|
||||
snapshot={
|
||||
"name": title,
|
||||
"content": content,
|
||||
"command": clean_command,
|
||||
"data": {},
|
||||
"meta": {},
|
||||
"access_control": access_control,
|
||||
},
|
||||
user_id=user_id,
|
||||
commit_message=None,
|
||||
created_at=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
# Step 5: Replace old table with new one
|
||||
op.drop_table("prompt")
|
||||
op.rename_table("prompt_new", "prompt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Step 1: Read new data
|
||||
prompt_table = sa.table(
|
||||
"prompt",
|
||||
sa.column("command", sa.String()),
|
||||
sa.column("name", sa.Text()),
|
||||
sa.column("created_at", sa.BigInteger()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("content", sa.Text()),
|
||||
sa.column("access_control", sa.JSON()),
|
||||
)
|
||||
|
||||
try:
|
||||
current_data = conn.execute(
|
||||
sa.select(
|
||||
prompt_table.c.command,
|
||||
prompt_table.c.name,
|
||||
prompt_table.c.created_at,
|
||||
prompt_table.c.user_id,
|
||||
prompt_table.c.content,
|
||||
prompt_table.c.access_control,
|
||||
)
|
||||
).fetchall()
|
||||
except Exception:
|
||||
current_data = []
|
||||
|
||||
# Step 2: Drop history and table
|
||||
op.drop_table("prompt_history")
|
||||
op.drop_table("prompt")
|
||||
|
||||
# Step 3: Recreate old table (command as PK?)
|
||||
# Assuming old schema:
|
||||
op.create_table(
|
||||
"prompt",
|
||||
sa.Column("command", sa.String(), primary_key=True),
|
||||
sa.Column("user_id", sa.String()),
|
||||
sa.Column("title", sa.Text()),
|
||||
sa.Column("content", sa.Text()),
|
||||
sa.Column("timestamp", sa.BigInteger()),
|
||||
sa.Column("access_control", sa.JSON()),
|
||||
sa.Column("id", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
# Step 4: Restore data
|
||||
old_prompt_table = sa.table(
|
||||
"prompt",
|
||||
sa.column("command", sa.String()),
|
||||
sa.column("user_id", sa.String()),
|
||||
sa.column("title", sa.Text()),
|
||||
sa.column("content", sa.Text()),
|
||||
sa.column("timestamp", sa.BigInteger()),
|
||||
sa.column("access_control", sa.JSON()),
|
||||
)
|
||||
|
||||
for row in current_data:
|
||||
command = row[0]
|
||||
name = row[1]
|
||||
created_at = row[2]
|
||||
user_id = row[3]
|
||||
content = row[4]
|
||||
access_control = row[5]
|
||||
|
||||
# Restore leading /
|
||||
old_command = (
|
||||
"/" + command if command and not command.startswith("/") else command
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.insert(old_prompt_table).values(
|
||||
command=old_command,
|
||||
user_id=user_id,
|
||||
title=name,
|
||||
content=content,
|
||||
timestamp=created_at,
|
||||
access_control=access_control,
|
||||
)
|
||||
)
|
||||
@@ -14,7 +14,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "37f288994c47"
|
||||
down_revision: Union[str, None] = "a5c220713937"
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "38d63c18f30f"
|
||||
down_revision: Union[str, None] = "3af16a1c9fb6"
|
||||
|
||||
@@ -12,7 +12,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6283dc0e4d8d"
|
||||
down_revision: Union[str, None] = "3e0e00844bb0"
|
||||
|
||||
@@ -11,7 +11,6 @@ import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, column, select
|
||||
import json
|
||||
|
||||
|
||||
revision = "6a39f3d8e55c"
|
||||
down_revision = "c0fbf31ca0db"
|
||||
branch_labels = None
|
||||
|
||||
-1
@@ -12,7 +12,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "81cc2ce44d79"
|
||||
down_revision: Union[str, None] = "6283dc0e4d8d"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Add chat_message table
|
||||
|
||||
Revision ID: 8452d01d26d7
|
||||
Revises: 374d2f66af06
|
||||
Create Date: 2026-02-01 04:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
revision: str = "8452d01d26d7"
|
||||
down_revision: Union[str, None] = "374d2f66af06"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Step 1: Create table
|
||||
op.create_table(
|
||||
"chat_message",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("chat_id", sa.Text(), nullable=False, index=True),
|
||||
sa.Column("user_id", sa.Text(), index=True),
|
||||
sa.Column("role", sa.Text(), nullable=False),
|
||||
sa.Column("parent_id", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.JSON(), nullable=True),
|
||||
sa.Column("output", sa.JSON(), nullable=True),
|
||||
sa.Column("model_id", sa.Text(), nullable=True, index=True),
|
||||
sa.Column("files", sa.JSON(), nullable=True),
|
||||
sa.Column("sources", sa.JSON(), nullable=True),
|
||||
sa.Column("embeds", sa.JSON(), nullable=True),
|
||||
sa.Column("done", sa.Boolean(), default=True),
|
||||
sa.Column("status_history", sa.JSON(), nullable=True),
|
||||
sa.Column("error", sa.JSON(), nullable=True),
|
||||
sa.Column("usage", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.BigInteger(), index=True),
|
||||
sa.Column("updated_at", sa.BigInteger()),
|
||||
sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
# Create composite indexes
|
||||
op.create_index(
|
||||
"chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"chat_message_model_created_idx", "chat_message", ["model_id", "created_at"]
|
||||
)
|
||||
op.create_index(
|
||||
"chat_message_user_created_idx", "chat_message", ["user_id", "created_at"]
|
||||
)
|
||||
|
||||
# Step 2: Backfill from existing chats
|
||||
conn = op.get_bind()
|
||||
|
||||
chat_table = sa.table(
|
||||
"chat",
|
||||
sa.column("id", sa.Text()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("chat", sa.JSON()),
|
||||
)
|
||||
|
||||
chat_message_table = sa.table(
|
||||
"chat_message",
|
||||
sa.column("id", sa.Text()),
|
||||
sa.column("chat_id", sa.Text()),
|
||||
sa.column("user_id", sa.Text()),
|
||||
sa.column("role", sa.Text()),
|
||||
sa.column("parent_id", sa.Text()),
|
||||
sa.column("content", sa.JSON()),
|
||||
sa.column("output", sa.JSON()),
|
||||
sa.column("model_id", sa.Text()),
|
||||
sa.column("files", sa.JSON()),
|
||||
sa.column("sources", sa.JSON()),
|
||||
sa.column("embeds", sa.JSON()),
|
||||
sa.column("done", sa.Boolean()),
|
||||
sa.column("status_history", sa.JSON()),
|
||||
sa.column("error", sa.JSON()),
|
||||
sa.column("usage", sa.JSON()),
|
||||
sa.column("created_at", sa.BigInteger()),
|
||||
sa.column("updated_at", sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Fetch all chats (excluding shared chats which have user_id starting with 'shared-')
|
||||
chats = conn.execute(
|
||||
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat).where(
|
||||
~chat_table.c.user_id.like("shared-%")
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
now = int(time.time())
|
||||
messages_inserted = 0
|
||||
messages_failed = 0
|
||||
|
||||
for chat_row in chats:
|
||||
chat_id = chat_row[0]
|
||||
user_id = chat_row[1]
|
||||
chat_data = chat_row[2]
|
||||
|
||||
if not chat_data:
|
||||
continue
|
||||
|
||||
# Handle both string and dict chat data
|
||||
if isinstance(chat_data, str):
|
||||
try:
|
||||
chat_data = json.loads(chat_data)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
history = chat_data.get("history", {})
|
||||
messages = history.get("messages", {})
|
||||
|
||||
for message_id, message in messages.items():
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
role = message.get("role")
|
||||
if not role:
|
||||
continue
|
||||
|
||||
timestamp = message.get("timestamp", now)
|
||||
|
||||
# Normalize timestamp: convert ms to seconds, validate range
|
||||
if timestamp > 10_000_000_000:
|
||||
timestamp = timestamp // 1000
|
||||
# Must be after 2020 and not too far in the future
|
||||
if timestamp < 1577836800 or timestamp > now + 86400:
|
||||
timestamp = now
|
||||
|
||||
# Use savepoint to allow individual insert failures without aborting transaction
|
||||
savepoint = conn.begin_nested()
|
||||
try:
|
||||
conn.execute(
|
||||
sa.insert(chat_message_table).values(
|
||||
id=f"{chat_id}-{message_id}",
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
parent_id=message.get("parentId"),
|
||||
content=message.get("content"),
|
||||
output=message.get("output"),
|
||||
model_id=message.get("model"),
|
||||
files=message.get("files"),
|
||||
sources=message.get("sources"),
|
||||
embeds=message.get("embeds"),
|
||||
done=message.get("done", True),
|
||||
status_history=message.get("statusHistory"),
|
||||
error=message.get("error"),
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
)
|
||||
savepoint.commit()
|
||||
messages_inserted += 1
|
||||
except Exception as e:
|
||||
savepoint.rollback()
|
||||
messages_failed += 1
|
||||
log.warning(f"Failed to insert message {message_id}: {e}")
|
||||
continue
|
||||
|
||||
log.info(
|
||||
f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("chat_message_user_created_idx", table_name="chat_message")
|
||||
op.drop_index("chat_message_model_created_idx", table_name="chat_message")
|
||||
op.drop_index("chat_message_chat_parent_idx", table_name="chat_message")
|
||||
op.drop_table("chat_message")
|
||||
-1
@@ -12,7 +12,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "90ef40d4714e"
|
||||
down_revision: Union[str, None] = "b10670c03dd5"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Add skill table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f1e2d3c4b5a6
|
||||
Create Date: 2026-02-11 09:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "f1e2d3c4b5a6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
if "skill" not in existing_tables:
|
||||
op.create_table(
|
||||
"skill",
|
||||
sa.Column("id", sa.String(), nullable=False, primary_key=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("meta", sa.JSON(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
op.create_index("idx_skill_user_id", "skill", ["user_id"])
|
||||
op.create_index("idx_skill_updated_at", "skill", ["updated_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_skill_updated_at", table_name="skill")
|
||||
op.drop_index("idx_skill_user_id", table_name="skill")
|
||||
op.drop_table("skill")
|
||||
@@ -173,12 +173,10 @@ def upgrade() -> None:
|
||||
for uid, api_key in users_with_keys:
|
||||
if api_key:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
sa.text("""
|
||||
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
|
||||
VALUES (:id, :user_id, :key, :created_at, :updated_at)
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": f"key_{uid}",
|
||||
"user_id": uid,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""add scim column to user table
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-02-13 14:19:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b2c3d4e5f6a7"
|
||||
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("user", sa.Column("scim", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "scim")
|
||||
@@ -12,7 +12,6 @@ import json
|
||||
from sqlalchemy.sql import table, column
|
||||
from sqlalchemy import String, Text, JSON, and_
|
||||
|
||||
|
||||
revision = "c29facfe716b"
|
||||
down_revision = "c69f45358db4"
|
||||
branch_labels = None
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c440947495f3"
|
||||
down_revision: Union[str, None] = "81cc2ce44d79"
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Add access_grant table
|
||||
|
||||
Revision ID: f1e2d3c4b5a6
|
||||
Revises: 8452d01d26d7
|
||||
Create Date: 2026-02-05 10:00:00.000000
|
||||
|
||||
Migrates from JSON access_control columns to normalized access_grant table.
|
||||
Access control semantics:
|
||||
- NULL: Public access (all users can read) -> insert user:* for read
|
||||
- {}: Private/owner-only (no grants) -> insert nothing
|
||||
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = "f1e2d3c4b5a6"
|
||||
down_revision: Union[str, None] = "8452d01d26d7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
# Create access_grant table
|
||||
if "access_grant" not in existing_tables:
|
||||
op.create_table(
|
||||
"access_grant",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("resource_type", sa.Text(), nullable=False),
|
||||
sa.Column("resource_id", sa.Text(), nullable=False),
|
||||
sa.Column("principal_type", sa.Text(), nullable=False),
|
||||
sa.Column("principal_id", sa.Text(), nullable=False),
|
||||
sa.Column("permission", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission",
|
||||
name="uq_access_grant_grant",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_access_grant_resource",
|
||||
"access_grant",
|
||||
["resource_type", "resource_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_access_grant_principal",
|
||||
"access_grant",
|
||||
["principal_type", "principal_id"],
|
||||
)
|
||||
|
||||
# Backfill existing access_control JSON data
|
||||
conn = op.get_bind()
|
||||
|
||||
# Tables with access_control JSON columns: (table_name, resource_type)
|
||||
resource_tables = [
|
||||
("knowledge", "knowledge"),
|
||||
("prompt", "prompt"),
|
||||
("tool", "tool"),
|
||||
("model", "model"),
|
||||
("note", "note"),
|
||||
("channel", "channel"),
|
||||
("file", "file"),
|
||||
]
|
||||
|
||||
now = int(time.time())
|
||||
inserted = set()
|
||||
|
||||
for table_name, resource_type in resource_tables:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
|
||||
# Query all rows
|
||||
try:
|
||||
result = conn.execute(
|
||||
sa.text(f'SELECT id, access_control FROM "{table_name}"')
|
||||
)
|
||||
rows = result.fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
resource_id = row[0]
|
||||
access_control_json = row[1]
|
||||
|
||||
# Handle NULL or JSON "null" = public access (user:* for read)
|
||||
# Could be Python None (SQL NULL) or string "null" (JSON null)
|
||||
# EXCEPTION: files with NULL are PRIVATE (owner-only), not public
|
||||
is_null = (
|
||||
access_control_json is None
|
||||
or access_control_json == "null"
|
||||
or (
|
||||
isinstance(access_control_json, str)
|
||||
and access_control_json.strip().lower() == "null"
|
||||
)
|
||||
)
|
||||
if is_null:
|
||||
# Files: NULL = private (no entry needed, owner has implicit access)
|
||||
# Other resources: NULL = public (insert user:* for read)
|
||||
if resource_type == "file":
|
||||
continue # Private - no entry needed
|
||||
|
||||
key = (resource_type, resource_id, "user", "*", "read")
|
||||
if key not in inserted:
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": "*",
|
||||
"permission": "read",
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# Handle JSON parsing
|
||||
if isinstance(access_control_json, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
access_control_json = json.loads(access_control_json)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Handle {} = private/owner-only - NO entries needed
|
||||
# Owner access is implicit, no grants to store
|
||||
if not access_control_json or not isinstance(access_control_json, dict):
|
||||
continue
|
||||
|
||||
# Check if it's effectively empty (no read/write keys with content)
|
||||
read_data = access_control_json.get("read", {})
|
||||
write_data = access_control_json.get("write", {})
|
||||
|
||||
has_read_grants = read_data.get("group_ids", []) or read_data.get(
|
||||
"user_ids", []
|
||||
)
|
||||
has_write_grants = write_data.get("group_ids", []) or write_data.get(
|
||||
"user_ids", []
|
||||
)
|
||||
|
||||
if not has_read_grants and not has_write_grants:
|
||||
# Empty permissions = private, no grants needed
|
||||
continue
|
||||
|
||||
# Extract permissions and insert into access_grant table
|
||||
for permission in ["read", "write"]:
|
||||
perm_data = access_control_json.get(permission, {})
|
||||
if not perm_data:
|
||||
continue
|
||||
|
||||
for group_id in perm_data.get("group_ids", []):
|
||||
key = (resource_type, resource_id, "group", group_id, permission)
|
||||
if key in inserted:
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "group",
|
||||
"principal_id": group_id,
|
||||
"permission": permission,
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for user_id in perm_data.get("user_ids", []):
|
||||
key = (resource_type, resource_id, "user", user_id, permission)
|
||||
if key in inserted:
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": user_id,
|
||||
"permission": permission,
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drop access_control columns from resource tables
|
||||
for table_name, _ in resource_tables:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
try:
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.drop_column("access_control")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
import json
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
# Resource tables mapping: (table_name, resource_type)
|
||||
resource_tables = [
|
||||
("knowledge", "knowledge"),
|
||||
("prompt", "prompt"),
|
||||
("tool", "tool"),
|
||||
("model", "model"),
|
||||
("note", "note"),
|
||||
("channel", "channel"),
|
||||
("file", "file"),
|
||||
]
|
||||
|
||||
# Step 1: Re-add access_control columns to resource tables
|
||||
for table_name, _ in resource_tables:
|
||||
try:
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.add_column(sa.Column("access_control", sa.JSON(), nullable=True))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 2: Query access_grant table and reconstruct JSON for each resource
|
||||
for table_name, resource_type in resource_tables:
|
||||
try:
|
||||
# Get all grants for this resource type
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT resource_id, principal_type, principal_id, permission
|
||||
FROM access_grant
|
||||
WHERE resource_type = :resource_type
|
||||
"""),
|
||||
{"resource_type": resource_type},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Group by resource_id and reconstruct JSON structure
|
||||
resource_grants = {}
|
||||
for row in rows:
|
||||
resource_id = row[0]
|
||||
principal_type = row[1]
|
||||
principal_id = row[2]
|
||||
permission = row[3]
|
||||
|
||||
if resource_id not in resource_grants:
|
||||
resource_grants[resource_id] = {
|
||||
"is_public": False,
|
||||
"read": {"group_ids": [], "user_ids": []},
|
||||
"write": {"group_ids": [], "user_ids": []},
|
||||
}
|
||||
|
||||
# Handle public access (user:* for read)
|
||||
if (
|
||||
principal_type == "user"
|
||||
and principal_id == "*"
|
||||
and permission == "read"
|
||||
):
|
||||
resource_grants[resource_id]["is_public"] = True
|
||||
continue
|
||||
|
||||
# Add to appropriate list
|
||||
if permission in ["read", "write"]:
|
||||
if principal_type == "group":
|
||||
if (
|
||||
principal_id
|
||||
not in resource_grants[resource_id][permission]["group_ids"]
|
||||
):
|
||||
resource_grants[resource_id][permission]["group_ids"].append(
|
||||
principal_id
|
||||
)
|
||||
elif principal_type == "user":
|
||||
if (
|
||||
principal_id
|
||||
not in resource_grants[resource_id][permission]["user_ids"]
|
||||
):
|
||||
resource_grants[resource_id][permission]["user_ids"].append(
|
||||
principal_id
|
||||
)
|
||||
|
||||
# Step 3: Update each resource with reconstructed JSON
|
||||
for resource_id, grants in resource_grants.items():
|
||||
if grants["is_public"]:
|
||||
# Public = NULL
|
||||
access_control_value = None
|
||||
elif (
|
||||
not grants["read"]["group_ids"]
|
||||
and not grants["read"]["user_ids"]
|
||||
and not grants["write"]["group_ids"]
|
||||
and not grants["write"]["user_ids"]
|
||||
):
|
||||
# No grants = should not happen (would mean no entries), default to {}
|
||||
access_control_value = json.dumps({})
|
||||
else:
|
||||
# Custom permissions
|
||||
access_control_value = json.dumps(
|
||||
{
|
||||
"read": grants["read"],
|
||||
"write": grants["write"],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'
|
||||
),
|
||||
{"access_control": access_control_value, "id": resource_id},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 4: Set all resources WITHOUT entries to private
|
||||
# For files: NULL means private (owner-only), so leave as NULL
|
||||
# For other resources: {} means private, so update to {}
|
||||
if resource_type != "file":
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(f"""
|
||||
UPDATE "{table_name}"
|
||||
SET access_control = :private_value
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT resource_id FROM access_grant WHERE resource_type = :resource_type
|
||||
)
|
||||
AND access_control IS NULL
|
||||
"""),
|
||||
{"private_value": json.dumps({}), "resource_type": resource_type},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# For files, NULL stays NULL - no action needed
|
||||
|
||||
# Step 5: Drop the access_grant table
|
||||
op.drop_index("idx_access_grant_principal", table_name="access_grant")
|
||||
op.drop_index("idx_access_grant_resource", table_name="access_grant")
|
||||
op.drop_table("access_grant")
|
||||
@@ -0,0 +1,838 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db_context
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
####################
|
||||
# AccessGrant DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class AccessGrant(Base):
|
||||
__tablename__ = "access_grant"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
resource_type = Column(
|
||||
Text, nullable=False
|
||||
) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
|
||||
resource_id = Column(Text, nullable=False)
|
||||
principal_type = Column(Text, nullable=False) # "user" or "group"
|
||||
principal_id = Column(
|
||||
Text, nullable=False
|
||||
) # user_id, group_id, or "*" (wildcard for public)
|
||||
permission = Column(Text, nullable=False) # "read" or "write"
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission",
|
||||
name="uq_access_grant_grant",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AccessGrantModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
principal_type: str
|
||||
principal_id: str
|
||||
permission: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class AccessGrantResponse(BaseModel):
|
||||
"""Slim grant model for API responses — resource context is implicit from the parent."""
|
||||
|
||||
id: str
|
||||
principal_type: str
|
||||
principal_id: str
|
||||
permission: str
|
||||
|
||||
@classmethod
|
||||
def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse":
|
||||
return cls(
|
||||
id=grant.id,
|
||||
principal_type=grant.principal_type,
|
||||
principal_id=grant.principal_id,
|
||||
permission=grant.permission,
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# Conversion utilities
|
||||
####################
|
||||
|
||||
|
||||
def access_control_to_grants(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
access_control: Optional[dict],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Convert an old-style access_control JSON dict to a flat list of grant dicts.
|
||||
|
||||
Semantics:
|
||||
- None → public read (user:* read) — except files which are private
|
||||
- {} → private/owner-only (no grants)
|
||||
- {read: {group_ids, user_ids}, write: {group_ids, user_ids}} → specific grants
|
||||
|
||||
Returns a list of dicts with keys: resource_type, resource_id, principal_type, principal_id, permission
|
||||
"""
|
||||
grants = []
|
||||
|
||||
if access_control is None:
|
||||
# NULL → public read (user:* for read)
|
||||
# Exception: files with NULL are private (owner-only), no grants needed
|
||||
if resource_type != "file":
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": "*",
|
||||
"permission": "read",
|
||||
}
|
||||
)
|
||||
return grants
|
||||
|
||||
# {} → private/owner-only, no grants
|
||||
if not access_control:
|
||||
return grants
|
||||
|
||||
# Parse structured permissions
|
||||
for permission in ["read", "write"]:
|
||||
perm_data = access_control.get(permission, {})
|
||||
if not perm_data:
|
||||
continue
|
||||
|
||||
for group_id in perm_data.get("group_ids", []):
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "group",
|
||||
"principal_id": group_id,
|
||||
"permission": permission,
|
||||
}
|
||||
)
|
||||
|
||||
for user_id in perm_data.get("user_ids", []):
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": user_id,
|
||||
"permission": permission,
|
||||
}
|
||||
)
|
||||
|
||||
return grants
|
||||
|
||||
|
||||
def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
|
||||
"""
|
||||
Normalize direct access_grants payloads from API forms.
|
||||
|
||||
Keeps only valid grants and removes duplicates by
|
||||
(principal_type, principal_id, permission).
|
||||
"""
|
||||
if not access_grants:
|
||||
return []
|
||||
|
||||
deduped = {}
|
||||
for grant in access_grants:
|
||||
if isinstance(grant, BaseModel):
|
||||
grant = grant.model_dump()
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
|
||||
principal_type = grant.get("principal_type")
|
||||
principal_id = grant.get("principal_id")
|
||||
permission = grant.get("permission")
|
||||
|
||||
if principal_type not in ("user", "group"):
|
||||
continue
|
||||
if permission not in ("read", "write"):
|
||||
continue
|
||||
if not isinstance(principal_id, str) or not principal_id:
|
||||
continue
|
||||
|
||||
key = (principal_type, principal_id, permission)
|
||||
deduped[key] = {
|
||||
"id": (
|
||||
grant.get("id")
|
||||
if isinstance(grant.get("id"), str) and grant.get("id")
|
||||
else str(uuid.uuid4())
|
||||
),
|
||||
"principal_type": principal_type,
|
||||
"principal_id": principal_id,
|
||||
"permission": permission,
|
||||
}
|
||||
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
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"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def grants_to_access_control(grants: list) -> Optional[dict]:
|
||||
"""
|
||||
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
|
||||
back to the old-style access_control JSON dict for backward compatibility.
|
||||
|
||||
Semantics:
|
||||
- [] (empty) → {} (private/owner-only)
|
||||
- Contains user:*:read → None (public), but write grants are preserved
|
||||
- Otherwise → {read: {group_ids, user_ids}, write: {group_ids, user_ids}}
|
||||
|
||||
Note: "public" (user:*:read) still allows additional write permissions
|
||||
to coexist. When the wildcard read is present the function returns None
|
||||
for the legacy dict, so callers that need write info should inspect the
|
||||
grants list directly.
|
||||
"""
|
||||
if not grants:
|
||||
return {} # No grants = private/owner-only
|
||||
|
||||
result = {
|
||||
"read": {"group_ids": [], "user_ids": []},
|
||||
"write": {"group_ids": [], "user_ids": []},
|
||||
}
|
||||
|
||||
is_public = False
|
||||
for grant in grants:
|
||||
if (
|
||||
grant.principal_type == "user"
|
||||
and grant.principal_id == "*"
|
||||
and grant.permission == "read"
|
||||
):
|
||||
is_public = True
|
||||
continue # Don't add wildcard to user_ids list
|
||||
|
||||
if grant.permission not in ("read", "write"):
|
||||
continue
|
||||
|
||||
if grant.principal_type == "group":
|
||||
if grant.principal_id not in result[grant.permission]["group_ids"]:
|
||||
result[grant.permission]["group_ids"].append(grant.principal_id)
|
||||
elif grant.principal_type == "user":
|
||||
if grant.principal_id not in result[grant.permission]["user_ids"]:
|
||||
result[grant.permission]["user_ids"].append(grant.principal_id)
|
||||
|
||||
if is_public:
|
||||
return None # Public read access
|
||||
|
||||
return result
|
||||
|
||||
|
||||
####################
|
||||
# Table Operations
|
||||
####################
|
||||
|
||||
|
||||
class AccessGrantsTable:
|
||||
def grant_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
principal_type: str,
|
||||
principal_id: str,
|
||||
permission: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[AccessGrantModel]:
|
||||
"""Add a single access grant. Idempotent (ignores duplicates)."""
|
||||
with get_db_context(db) as db:
|
||||
# Check for existing grant
|
||||
existing = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_id,
|
||||
permission=permission,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return AccessGrantModel.model_validate(existing)
|
||||
|
||||
grant = AccessGrant(
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_id,
|
||||
permission=permission,
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(grant)
|
||||
db.commit()
|
||||
db.refresh(grant)
|
||||
return AccessGrantModel.model_validate(grant)
|
||||
|
||||
def revoke_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
principal_type: str,
|
||||
principal_id: str,
|
||||
permission: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
"""Remove a single access grant."""
|
||||
with get_db_context(db) as db:
|
||||
deleted = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_id,
|
||||
permission=permission,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
db.commit()
|
||||
return deleted > 0
|
||||
|
||||
def revoke_all_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> int:
|
||||
"""Remove all access grants for a resource."""
|
||||
with get_db_context(db) as db:
|
||||
deleted = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
db.commit()
|
||||
return deleted
|
||||
|
||||
def set_access_control(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
access_control: Optional[dict],
|
||||
db: Optional[Session] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""
|
||||
Replace all grants for a resource from an access_control JSON dict.
|
||||
This is the primary bridge for backward compat with the frontend.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
# Delete all existing grants for this resource
|
||||
db.query(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
).delete()
|
||||
|
||||
# Convert JSON to grant dicts
|
||||
grant_dicts = access_control_to_grants(
|
||||
resource_type, resource_id, access_control
|
||||
)
|
||||
|
||||
# Insert new grants
|
||||
results = []
|
||||
for grant_dict in grant_dicts:
|
||||
grant = AccessGrant(
|
||||
id=str(uuid.uuid4()),
|
||||
**grant_dict,
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(grant)
|
||||
results.append(grant)
|
||||
|
||||
db.commit()
|
||||
|
||||
return [AccessGrantModel.model_validate(g) for g in results]
|
||||
|
||||
def set_access_grants(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
access_grants: Optional[list],
|
||||
db: Optional[Session] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""
|
||||
Replace all grants for a resource from a direct access_grants list.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
db.query(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
).delete()
|
||||
|
||||
normalized_grants = normalize_access_grants(access_grants)
|
||||
|
||||
results = []
|
||||
for grant_dict in normalized_grants:
|
||||
grant = AccessGrant(
|
||||
id=grant_dict["id"],
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=grant_dict["principal_type"],
|
||||
principal_id=grant_dict["principal_id"],
|
||||
permission=grant_dict["permission"],
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(grant)
|
||||
results.append(grant)
|
||||
|
||||
db.commit()
|
||||
return [AccessGrantModel.model_validate(g) for g in results]
|
||||
|
||||
def get_access_control(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Reconstruct the old-style access_control JSON dict from grants.
|
||||
For backward compat with the frontend.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grant_models = [AccessGrantModel.model_validate(g) for g in grants]
|
||||
return grants_to_access_control(grant_models)
|
||||
|
||||
def get_grants_by_resource(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""Get all grants for a specific resource."""
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [AccessGrantModel.model_validate(g) for g in grants]
|
||||
|
||||
def has_access(
|
||||
self,
|
||||
user_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
permission: str = "read",
|
||||
user_group_ids: Optional[set[str]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has the specified permission on a resource.
|
||||
|
||||
Access is granted if any of the following is true:
|
||||
- There's a grant for user:* (public) with the requested permission
|
||||
- There's a grant for the specific user with the requested permission
|
||||
- There's a grant for any of the user's groups with the requested permission
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
# Build conditions for matching grants
|
||||
conditions = [
|
||||
# Public access
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
),
|
||||
# Direct user access
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
),
|
||||
]
|
||||
|
||||
# Group access
|
||||
if user_group_ids is None:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
if user_group_ids:
|
||||
conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(user_group_ids),
|
||||
)
|
||||
)
|
||||
|
||||
exists = (
|
||||
db.query(AccessGrant)
|
||||
.filter(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == resource_id,
|
||||
AccessGrant.permission == permission,
|
||||
or_(*conditions),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return exists is not None
|
||||
|
||||
def get_accessible_resource_ids(
|
||||
self,
|
||||
user_id: str,
|
||||
resource_type: str,
|
||||
resource_ids: list[str],
|
||||
permission: str = "read",
|
||||
user_group_ids: Optional[set[str]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> set[str]:
|
||||
"""
|
||||
Batch check: return the subset of resource_ids that the user can access.
|
||||
|
||||
This replaces calling has_access() in a loop (N+1) with a single query.
|
||||
"""
|
||||
if not resource_ids:
|
||||
return set()
|
||||
|
||||
with get_db_context(db) as db:
|
||||
conditions = [
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
),
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
),
|
||||
]
|
||||
|
||||
if user_group_ids is None:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
if user_group_ids:
|
||||
conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(user_group_ids),
|
||||
)
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(AccessGrant.resource_id)
|
||||
.filter(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id.in_(resource_ids),
|
||||
AccessGrant.permission == permission,
|
||||
or_(*conditions),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return {row[0] for row in rows}
|
||||
|
||||
def get_users_with_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
permission: str = "read",
|
||||
db: Optional[Session] = None,
|
||||
) -> list:
|
||||
"""
|
||||
Get all users who have the specified permission on a resource.
|
||||
Returns a list of UserModel instances.
|
||||
"""
|
||||
from open_webui.models.users import Users, UserModel
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
permission=permission,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Check for public access
|
||||
for grant in grants:
|
||||
if grant.principal_type == "user" and grant.principal_id == "*":
|
||||
result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
|
||||
return result.get("users", [])
|
||||
|
||||
user_ids_with_access = set()
|
||||
|
||||
for grant in grants:
|
||||
if grant.principal_type == "user":
|
||||
user_ids_with_access.add(grant.principal_id)
|
||||
elif grant.principal_type == "group":
|
||||
group_user_ids = Groups.get_group_user_ids_by_id(
|
||||
grant.principal_id, db=db
|
||||
)
|
||||
if group_user_ids:
|
||||
user_ids_with_access.update(group_user_ids)
|
||||
|
||||
if not user_ids_with_access:
|
||||
return []
|
||||
|
||||
return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
|
||||
|
||||
def has_permission_filter(
|
||||
self,
|
||||
db,
|
||||
query,
|
||||
DocumentModel,
|
||||
filter: dict,
|
||||
resource_type: str,
|
||||
permission: str = "read",
|
||||
):
|
||||
"""
|
||||
Apply access control filtering to a SQLAlchemy query by JOINing with access_grant.
|
||||
|
||||
This replaces the old JSON-column-based filtering with a proper relational JOIN.
|
||||
"""
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
if permission == "read_only":
|
||||
return self._has_read_only_permission_filter(
|
||||
db, query, DocumentModel, filter, resource_type
|
||||
)
|
||||
|
||||
# Build principal conditions
|
||||
principal_conditions = []
|
||||
|
||||
if group_ids or user_id:
|
||||
# Public access: user:* read
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
# Owner always has access
|
||||
principal_conditions.append(DocumentModel.user_id == user_id)
|
||||
|
||||
# Direct user grant
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
if group_ids:
|
||||
# Group grants
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
)
|
||||
|
||||
if not principal_conditions:
|
||||
return query
|
||||
|
||||
# LEFT JOIN access_grant and filter
|
||||
# We use a subquery approach to avoid duplicates from multiple matching grants
|
||||
from sqlalchemy import exists as sa_exists, select
|
||||
|
||||
grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == permission,
|
||||
or_(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
if user_id
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
if group_ids
|
||||
else []
|
||||
),
|
||||
),
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Owner OR has a matching grant
|
||||
owner_or_grant = [grant_exists]
|
||||
if user_id:
|
||||
owner_or_grant.append(DocumentModel.user_id == user_id)
|
||||
|
||||
query = query.filter(or_(*owner_or_grant))
|
||||
return query
|
||||
|
||||
def _has_read_only_permission_filter(
|
||||
self,
|
||||
db,
|
||||
query,
|
||||
DocumentModel,
|
||||
filter: dict,
|
||||
resource_type: str,
|
||||
):
|
||||
"""
|
||||
Filter for items where user has read BUT NOT write access.
|
||||
Public items are NOT considered read_only.
|
||||
"""
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
from sqlalchemy import exists as sa_exists, select
|
||||
|
||||
# Has read grant (not public)
|
||||
read_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "read",
|
||||
or_(
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
if user_id
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
if group_ids
|
||||
else []
|
||||
),
|
||||
),
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Does NOT have write grant
|
||||
write_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "write",
|
||||
or_(
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
if user_id
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
if group_ids
|
||||
else []
|
||||
),
|
||||
),
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Is NOT public
|
||||
public_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "read",
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
|
||||
|
||||
# Not owner
|
||||
if user_id:
|
||||
conditions.append(DocumentModel.user_id != user_id)
|
||||
|
||||
query = query.filter(and_(*conditions))
|
||||
return query
|
||||
|
||||
|
||||
AccessGrants = AccessGrantsTable()
|
||||
@@ -4,8 +4,9 @@ from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.users import UserModel, UserProfileImageResponse, Users
|
||||
from pydantic import BaseModel
|
||||
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import Boolean, Column, String, Text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -74,6 +75,13 @@ class SignupForm(BaseModel):
|
||||
password: str
|
||||
profile_image_url: Optional[str] = "/user.png"
|
||||
|
||||
@field_validator("profile_image_url")
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None:
|
||||
return validate_profile_image_url(v)
|
||||
return v
|
||||
|
||||
|
||||
class AddUserForm(SignupForm):
|
||||
role: Optional[str] = "pending"
|
||||
@@ -155,10 +163,17 @@ class AuthsTable:
|
||||
log.info(f"authenticate_user_by_email: {email}")
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
auth = db.query(Auth).filter_by(email=email, active=True).first()
|
||||
if auth:
|
||||
user = Users.get_user_by_id(auth.id, db=db)
|
||||
return user
|
||||
# Single JOIN query instead of two separate queries
|
||||
result = (
|
||||
db.query(Auth, User)
|
||||
.join(User, Auth.id == User.id)
|
||||
.filter(Auth.email == email, Auth.active == True)
|
||||
.first()
|
||||
)
|
||||
if result:
|
||||
_, user = result
|
||||
return UserModel.model_validate(user)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -7,8 +7,12 @@ from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import (
|
||||
AccessGrantModel,
|
||||
AccessGrants,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
@@ -47,7 +51,6 @@ class Channel(Base):
|
||||
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
@@ -76,7 +79,7 @@ class ChannelModel(BaseModel):
|
||||
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
created_at: int # timestamp in epoch (time_ns)
|
||||
|
||||
@@ -237,7 +240,7 @@ class ChannelForm(BaseModel):
|
||||
is_private: Optional[bool] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
group_ids: Optional[list[str]] = None
|
||||
user_ids: Optional[list[str]] = None
|
||||
|
||||
@@ -252,6 +255,20 @@ class ChannelWebhookForm(BaseModel):
|
||||
|
||||
|
||||
class ChannelTable:
|
||||
def _get_access_grants(
|
||||
self, channel_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("channel", channel_id, db=db)
|
||||
|
||||
def _to_channel_model(
|
||||
self, channel: Channel, db: Optional[Session] = None
|
||||
) -> ChannelModel:
|
||||
channel_data = ChannelModel.model_validate(channel).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
access_grants = self._get_access_grants(channel_data["id"], db=db)
|
||||
channel_data["access_grants"] = access_grants
|
||||
return ChannelModel.model_validate(channel_data)
|
||||
|
||||
def _collect_unique_user_ids(
|
||||
self,
|
||||
@@ -316,16 +333,17 @@ class ChannelTable:
|
||||
with get_db_context(db) as db:
|
||||
channel = ChannelModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"type": form_data.type if form_data.type else None,
|
||||
"name": form_data.name.lower(),
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time_ns()),
|
||||
"updated_at": int(time.time_ns()),
|
||||
"access_grants": [],
|
||||
}
|
||||
)
|
||||
new_channel = Channel(**channel.model_dump())
|
||||
new_channel = Channel(**channel.model_dump(exclude={"access_grants"}))
|
||||
|
||||
if form_data.type in ["group", "dm"]:
|
||||
users = self._collect_unique_user_ids(
|
||||
@@ -342,54 +360,25 @@ class ChannelTable:
|
||||
db.add_all(memberships)
|
||||
db.add(new_channel)
|
||||
db.commit()
|
||||
return channel
|
||||
AccessGrants.set_access_grants(
|
||||
"channel", new_channel.id, form_data.access_grants, db=db
|
||||
)
|
||||
return self._to_channel_model(new_channel, db=db)
|
||||
|
||||
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
|
||||
with get_db_context(db) as db:
|
||||
channels = db.query(Channel).all()
|
||||
return [ChannelModel.model_validate(channel) for channel in channels]
|
||||
return [self._to_channel_model(channel, db=db) for channel in channels]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
# Public access
|
||||
conditions = []
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
Channel.access_control.is_(None),
|
||||
cast(Channel.access_control, String) == "null",
|
||||
]
|
||||
)
|
||||
|
||||
# User-level permission
|
||||
if user_id:
|
||||
conditions.append(Channel.user_id == user_id)
|
||||
|
||||
# Group-level permission
|
||||
if group_ids:
|
||||
group_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_conditions.append(
|
||||
Channel.access_control[permission]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_conditions.append(
|
||||
cast(
|
||||
Channel.access_control[permission]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
conditions.append(or_(*group_conditions))
|
||||
|
||||
if conditions:
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query
|
||||
return AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Channel,
|
||||
filter=filter,
|
||||
resource_type="channel",
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
def get_channels_by_user_id(
|
||||
self, user_id: str, db: Optional[Session] = None
|
||||
@@ -428,7 +417,7 @@ class ChannelTable:
|
||||
standard_channels = query.all()
|
||||
|
||||
all_channels = membership_channels + standard_channels
|
||||
return [ChannelModel.model_validate(c) for c in all_channels]
|
||||
return [self._to_channel_model(c, db=db) for c in all_channels]
|
||||
|
||||
def get_dm_channel_by_user_ids(
|
||||
self, user_ids: list[str], db: Optional[Session] = None
|
||||
@@ -463,7 +452,7 @@ class ChannelTable:
|
||||
.first()
|
||||
)
|
||||
|
||||
return ChannelModel.model_validate(channel) if channel else None
|
||||
return self._to_channel_model(channel, db=db) if channel else None
|
||||
|
||||
def add_members_to_channel(
|
||||
self,
|
||||
@@ -722,7 +711,7 @@ class ChannelTable:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
channel = db.query(Channel).filter(Channel.id == id).first()
|
||||
return ChannelModel.model_validate(channel) if channel else None
|
||||
return self._to_channel_model(channel, db=db) if channel else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -735,7 +724,7 @@ class ChannelTable:
|
||||
)
|
||||
channel_ids = [cf.channel_id for cf in channel_files]
|
||||
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
|
||||
return [ChannelModel.model_validate(channel) for channel in channels]
|
||||
return [self._to_channel_model(channel, db=db) for channel in channels]
|
||||
|
||||
def get_channels_by_file_id_and_user_id(
|
||||
self, file_id: str, user_id: str, db: Optional[Session] = None
|
||||
@@ -783,7 +772,7 @@ class ChannelTable:
|
||||
.first()
|
||||
)
|
||||
if membership:
|
||||
allowed_channels.append(ChannelModel.model_validate(channel))
|
||||
allowed_channels.append(self._to_channel_model(channel, db=db))
|
||||
continue
|
||||
|
||||
# --- Case B: standard channel => rely on ACL permissions ---
|
||||
@@ -798,7 +787,7 @@ class ChannelTable:
|
||||
|
||||
allowed = query.first()
|
||||
if allowed:
|
||||
allowed_channels.append(ChannelModel.model_validate(allowed))
|
||||
allowed_channels.append(self._to_channel_model(allowed, db=db))
|
||||
|
||||
return allowed_channels
|
||||
|
||||
@@ -832,7 +821,7 @@ class ChannelTable:
|
||||
.first()
|
||||
)
|
||||
if membership:
|
||||
return ChannelModel.model_validate(channel)
|
||||
return self._to_channel_model(channel, db=db)
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -854,7 +843,7 @@ class ChannelTable:
|
||||
|
||||
channel_allowed = query.first()
|
||||
return (
|
||||
ChannelModel.model_validate(channel_allowed)
|
||||
self._to_channel_model(channel_allowed, db=db)
|
||||
if channel_allowed
|
||||
else None
|
||||
)
|
||||
@@ -874,11 +863,14 @@ class ChannelTable:
|
||||
channel.data = form_data.data
|
||||
channel.meta = form_data.meta
|
||||
|
||||
channel.access_control = form_data.access_control
|
||||
if form_data.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"channel", id, form_data.access_grants, db=db
|
||||
)
|
||||
channel.updated_at = int(time.time_ns())
|
||||
|
||||
db.commit()
|
||||
return ChannelModel.model_validate(channel) if channel else None
|
||||
return self._to_channel_model(channel, db=db) if channel else None
|
||||
|
||||
def add_file_to_channel_by_id(
|
||||
self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None
|
||||
@@ -947,6 +939,7 @@ class ChannelTable:
|
||||
|
||||
def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("channel", id, db=db)
|
||||
db.query(Channel).filter(Channel.id == id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db_context
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Text,
|
||||
JSON,
|
||||
Index,
|
||||
func,
|
||||
)
|
||||
|
||||
####################
|
||||
# Helpers
|
||||
####################
|
||||
|
||||
|
||||
def _normalize_timestamp(timestamp: int) -> float:
|
||||
"""Normalize and validate timestamp. Returns current time if invalid."""
|
||||
now = time.time()
|
||||
|
||||
# Convert milliseconds to seconds if needed
|
||||
if timestamp > 10_000_000_000:
|
||||
timestamp = timestamp / 1000
|
||||
|
||||
# Validate: must be after 2020 and not in the future (with 1 day tolerance)
|
||||
min_valid = 1577836800 # 2020-01-01 00:00:00 UTC
|
||||
max_valid = now + 86400 # 1 day in the future (clock skew tolerance)
|
||||
|
||||
if timestamp < min_valid or timestamp > max_valid:
|
||||
return now
|
||||
|
||||
return timestamp
|
||||
|
||||
|
||||
####################
|
||||
# ChatMessage DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_message"
|
||||
|
||||
# Identity
|
||||
id = Column(Text, primary_key=True)
|
||||
chat_id = Column(
|
||||
Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id = Column(Text, index=True)
|
||||
|
||||
# Structure
|
||||
role = Column(Text, nullable=False) # user, assistant, system
|
||||
parent_id = Column(Text, nullable=True)
|
||||
|
||||
# Content
|
||||
content = Column(JSON, nullable=True) # Can be str or list of blocks
|
||||
output = Column(JSON, nullable=True)
|
||||
|
||||
# Model (for assistant messages)
|
||||
model_id = Column(Text, nullable=True, index=True)
|
||||
|
||||
# Attachments
|
||||
files = Column(JSON, nullable=True)
|
||||
sources = Column(JSON, nullable=True)
|
||||
embeds = Column(JSON, nullable=True)
|
||||
|
||||
# Status
|
||||
done = Column(Boolean, default=True)
|
||||
status_history = Column(JSON, nullable=True)
|
||||
error = Column(JSON, nullable=True)
|
||||
|
||||
# Usage (tokens, timing, etc.)
|
||||
usage = Column(JSON, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(BigInteger, index=True)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
__table_args__ = (
|
||||
Index("chat_message_chat_parent_idx", "chat_id", "parent_id"),
|
||||
Index("chat_message_model_created_idx", "model_id", "created_at"),
|
||||
Index("chat_message_user_created_idx", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# Pydantic Models
|
||||
####################
|
||||
|
||||
|
||||
class ChatMessageModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
chat_id: str
|
||||
user_id: str
|
||||
role: str
|
||||
parent_id: Optional[str] = None
|
||||
content: Optional[Any] = None # str or list of blocks
|
||||
output: Optional[list] = None
|
||||
model_id: Optional[str] = None
|
||||
files: Optional[list] = None
|
||||
sources: Optional[list] = None
|
||||
embeds: Optional[list] = None
|
||||
done: bool = True
|
||||
status_history: Optional[list] = None
|
||||
error: Optional[dict | str] = None
|
||||
usage: Optional[dict] = None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
####################
|
||||
# Table Operations
|
||||
####################
|
||||
|
||||
|
||||
class ChatMessageTable:
|
||||
def upsert_message(
|
||||
self,
|
||||
message_id: str,
|
||||
chat_id: str,
|
||||
user_id: str,
|
||||
data: dict,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[ChatMessageModel]:
|
||||
"""Insert or update a chat message."""
|
||||
with get_db_context(db) as db:
|
||||
now = int(time.time())
|
||||
timestamp = data.get("timestamp", now)
|
||||
|
||||
# Use composite ID: {chat_id}-{message_id}
|
||||
composite_id = f"{chat_id}-{message_id}"
|
||||
|
||||
existing = db.get(ChatMessage, composite_id)
|
||||
if existing:
|
||||
# Update existing
|
||||
if "role" in data:
|
||||
existing.role = data["role"]
|
||||
if "parent_id" in data:
|
||||
existing.parent_id = data.get("parent_id") or data.get("parentId")
|
||||
if "content" in data:
|
||||
existing.content = data.get("content")
|
||||
if "output" in data:
|
||||
existing.output = data.get("output")
|
||||
if "model_id" in data or "model" in data:
|
||||
existing.model_id = data.get("model_id") or data.get("model")
|
||||
if "files" in data:
|
||||
existing.files = data.get("files")
|
||||
if "sources" in data:
|
||||
existing.sources = data.get("sources")
|
||||
if "embeds" in data:
|
||||
existing.embeds = data.get("embeds")
|
||||
if "done" in data:
|
||||
existing.done = data.get("done", True)
|
||||
if "status_history" in data or "statusHistory" in data:
|
||||
existing.status_history = data.get("status_history") or data.get(
|
||||
"statusHistory"
|
||||
)
|
||||
if "error" in data:
|
||||
existing.error = data.get("error")
|
||||
# Extract usage - check direct field first, then info.usage
|
||||
usage = data.get("usage")
|
||||
if not usage:
|
||||
info = data.get("info", {})
|
||||
usage = info.get("usage") if info else None
|
||||
if usage:
|
||||
existing.usage = usage
|
||||
existing.updated_at = now
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return ChatMessageModel.model_validate(existing)
|
||||
else:
|
||||
# Insert new
|
||||
# Extract usage - check direct field first, then info.usage
|
||||
usage = data.get("usage")
|
||||
if not usage:
|
||||
info = data.get("info", {})
|
||||
usage = info.get("usage") if info else None
|
||||
message = ChatMessage(
|
||||
id=composite_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
role=data.get("role", "user"),
|
||||
parent_id=data.get("parent_id") or data.get("parentId"),
|
||||
content=data.get("content"),
|
||||
output=data.get("output"),
|
||||
model_id=data.get("model_id") or data.get("model"),
|
||||
files=data.get("files"),
|
||||
sources=data.get("sources"),
|
||||
embeds=data.get("embeds"),
|
||||
done=data.get("done", True),
|
||||
status_history=data.get("status_history")
|
||||
or data.get("statusHistory"),
|
||||
error=data.get("error"),
|
||||
usage=usage,
|
||||
created_at=timestamp,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
return ChatMessageModel.model_validate(message)
|
||||
|
||||
def get_message_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[ChatMessageModel]:
|
||||
with get_db_context(db) as db:
|
||||
message = db.get(ChatMessage, id)
|
||||
return ChatMessageModel.model_validate(message) if message else None
|
||||
|
||||
def get_messages_by_chat_id(
|
||||
self, chat_id: str, db: Optional[Session] = None
|
||||
) -> list[ChatMessageModel]:
|
||||
with get_db_context(db) as db:
|
||||
messages = (
|
||||
db.query(ChatMessage)
|
||||
.filter_by(chat_id=chat_id)
|
||||
.order_by(ChatMessage.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||
|
||||
def get_messages_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[ChatMessageModel]:
|
||||
with get_db_context(db) as db:
|
||||
messages = (
|
||||
db.query(ChatMessage)
|
||||
.filter_by(user_id=user_id)
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||
|
||||
def get_messages_by_model_id(
|
||||
self,
|
||||
model_id: str,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[ChatMessageModel]:
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(ChatMessage).filter_by(model_id=model_id)
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
messages = (
|
||||
query.order_by(ChatMessage.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||
|
||||
def get_chat_ids_by_model_id(
|
||||
self,
|
||||
model_id: str,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[str]:
|
||||
"""Get distinct chat_ids that used a specific model."""
|
||||
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(
|
||||
ChatMessage.chat_id,
|
||||
func.max(ChatMessage.created_at).label("last_message_at"),
|
||||
).filter(ChatMessage.model_id == model_id)
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
|
||||
# Group by chat_id and order by most recent message in each chat
|
||||
chat_ids = (
|
||||
query.group_by(ChatMessage.chat_id)
|
||||
.order_by(func.max(ChatMessage.created_at).desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [chat_id for chat_id, _ in chat_ids]
|
||||
|
||||
def delete_messages_by_chat_id(
|
||||
self, chat_id: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
db.query(ChatMessage).filter_by(chat_id=chat_id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
# Analytics methods
|
||||
def get_message_count_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, int]:
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
query = db.query(
|
||||
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
|
||||
).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.model_id.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.model_id).all()
|
||||
return {row.model_id: row.count for row in results}
|
||||
|
||||
def get_token_usage_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Aggregate token usage by model using database-level aggregation."""
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func, cast, Integer
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
dialect = db.bind.dialect.name
|
||||
|
||||
if dialect == "sqlite":
|
||||
input_tokens = cast(
|
||||
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
|
||||
)
|
||||
elif dialect == "postgresql":
|
||||
# Use json_extract_path_text for PostgreSQL JSON columns
|
||||
input_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
|
||||
Integer,
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
|
||||
Integer,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||
|
||||
query = db.query(
|
||||
ChatMessage.model_id,
|
||||
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
|
||||
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
|
||||
func.count(ChatMessage.id).label("message_count"),
|
||||
).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.model_id.isnot(None),
|
||||
ChatMessage.usage.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.model_id).all()
|
||||
|
||||
return {
|
||||
row.model_id: {
|
||||
"input_tokens": row.input_tokens,
|
||||
"output_tokens": row.output_tokens,
|
||||
"total_tokens": row.input_tokens + row.output_tokens,
|
||||
"message_count": row.message_count,
|
||||
}
|
||||
for row in results
|
||||
}
|
||||
|
||||
def get_token_usage_by_user(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Aggregate token usage by user using database-level aggregation."""
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func, cast, Integer
|
||||
|
||||
dialect = db.bind.dialect.name
|
||||
|
||||
if dialect == "sqlite":
|
||||
input_tokens = cast(
|
||||
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
|
||||
)
|
||||
elif dialect == "postgresql":
|
||||
# Use json_extract_path_text for PostgreSQL JSON columns
|
||||
input_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "input_tokens"),
|
||||
Integer,
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, "output_tokens"),
|
||||
Integer,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported dialect: {dialect}")
|
||||
|
||||
query = db.query(
|
||||
ChatMessage.user_id,
|
||||
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
|
||||
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
|
||||
func.count(ChatMessage.id).label("message_count"),
|
||||
).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.user_id.isnot(None),
|
||||
ChatMessage.usage.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
|
||||
results = query.group_by(ChatMessage.user_id).all()
|
||||
|
||||
return {
|
||||
row.user_id: {
|
||||
"input_tokens": row.input_tokens,
|
||||
"output_tokens": row.output_tokens,
|
||||
"total_tokens": row.input_tokens + row.output_tokens,
|
||||
"message_count": row.message_count,
|
||||
}
|
||||
for row in results
|
||||
}
|
||||
|
||||
def get_message_count_by_user(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, int]:
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
query = db.query(
|
||||
ChatMessage.user_id, func.count(ChatMessage.id).label("count")
|
||||
).filter(~ChatMessage.user_id.like("shared-%"))
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.user_id).all()
|
||||
return {row.user_id: row.count for row in results}
|
||||
|
||||
def get_message_count_by_chat(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, int]:
|
||||
with get_db_context(db) as db:
|
||||
from sqlalchemy import func
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
query = db.query(
|
||||
ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
|
||||
).filter(~ChatMessage.user_id.like("shared-%"))
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.group_by(ChatMessage.chat_id).all()
|
||||
return {row.chat_id: row.count for row in results}
|
||||
|
||||
def get_daily_message_counts_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
group_id: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Get message counts grouped by day and model."""
|
||||
with get_db_context(db) as db:
|
||||
from datetime import datetime, timedelta
|
||||
from open_webui.models.groups import GroupMember
|
||||
|
||||
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.model_id.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = (
|
||||
db.query(GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
results = query.all()
|
||||
|
||||
# Group by date -> model -> count
|
||||
daily_counts: dict[str, dict[str, int]] = {}
|
||||
for timestamp, model_id in results:
|
||||
date_str = datetime.fromtimestamp(
|
||||
_normalize_timestamp(timestamp)
|
||||
).strftime("%Y-%m-%d")
|
||||
if date_str not in daily_counts:
|
||||
daily_counts[date_str] = {}
|
||||
daily_counts[date_str][model_id] = (
|
||||
daily_counts[date_str].get(model_id, 0) + 1
|
||||
)
|
||||
|
||||
# Fill in missing days
|
||||
if start_date and end_date:
|
||||
current = datetime.fromtimestamp(_normalize_timestamp(start_date))
|
||||
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
|
||||
while current <= end_dt:
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
if date_str not in daily_counts:
|
||||
daily_counts[date_str] = {}
|
||||
current += timedelta(days=1)
|
||||
|
||||
return daily_counts
|
||||
|
||||
def get_hourly_message_counts_by_model(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Get message counts grouped by hour and model."""
|
||||
with get_db_context(db) as db:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.model_id.isnot(None),
|
||||
~ChatMessage.user_id.like("shared-%"),
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
|
||||
results = query.all()
|
||||
|
||||
# Group by hour -> model -> count
|
||||
hourly_counts: dict[str, dict[str, int]] = {}
|
||||
for timestamp, model_id in results:
|
||||
hour_str = datetime.fromtimestamp(
|
||||
_normalize_timestamp(timestamp)
|
||||
).strftime("%Y-%m-%d %H:00")
|
||||
if hour_str not in hourly_counts:
|
||||
hourly_counts[hour_str] = {}
|
||||
hourly_counts[hour_str][model_id] = (
|
||||
hourly_counts[hour_str].get(model_id, 0) + 1
|
||||
)
|
||||
|
||||
# Fill in missing hours
|
||||
if start_date and end_date:
|
||||
current = datetime.fromtimestamp(
|
||||
_normalize_timestamp(start_date)
|
||||
).replace(minute=0, second=0, microsecond=0)
|
||||
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
|
||||
while current <= end_dt:
|
||||
hour_str = current.strftime("%Y-%m-%d %H:00")
|
||||
if hour_str not in hourly_counts:
|
||||
hourly_counts[hour_str] = {}
|
||||
current += timedelta(hours=1)
|
||||
|
||||
return hourly_counts
|
||||
|
||||
|
||||
ChatMessages = ChatMessageTable()
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.tags import TagModel, Tag, Tags
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.chat_messages import ChatMessage, ChatMessages
|
||||
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -168,6 +169,14 @@ class ChatTitleIdResponse(BaseModel):
|
||||
created_at: int
|
||||
|
||||
|
||||
class SharedChatResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
share_id: Optional[str] = None
|
||||
updated_at: int
|
||||
created_at: int
|
||||
|
||||
|
||||
class ChatListResponse(BaseModel):
|
||||
items: list[ChatModel]
|
||||
total: int
|
||||
@@ -306,6 +315,24 @@ class ChatTable:
|
||||
db.add(chat_item)
|
||||
db.commit()
|
||||
db.refresh(chat_item)
|
||||
|
||||
# Dual-write initial messages to chat_message table
|
||||
try:
|
||||
history = form_data.chat.get("history", {})
|
||||
messages = history.get("messages", {})
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get("role"):
|
||||
ChatMessages.upsert_message(
|
||||
message_id=message_id,
|
||||
chat_id=id,
|
||||
user_id=user_id,
|
||||
data=message,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f"Failed to write initial messages to chat_message table: {e}"
|
||||
)
|
||||
|
||||
return ChatModel.model_validate(chat_item) if chat_item else None
|
||||
|
||||
def _chat_import_form_to_chat_model(
|
||||
@@ -348,6 +375,25 @@ class ChatTable:
|
||||
|
||||
db.add_all(chats)
|
||||
db.commit()
|
||||
|
||||
# Dual-write messages to chat_message table
|
||||
try:
|
||||
for form_data, chat_obj in zip(chat_import_forms, chats):
|
||||
history = form_data.chat.get("history", {})
|
||||
messages = history.get("messages", {})
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get("role"):
|
||||
ChatMessages.upsert_message(
|
||||
message_id=message_id,
|
||||
chat_id=chat_obj.id,
|
||||
user_id=user_id,
|
||||
data=message,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f"Failed to write imported messages to chat_message table: {e}"
|
||||
)
|
||||
|
||||
return [ChatModel.model_validate(chat) for chat in chats]
|
||||
|
||||
def update_chat_by_id(
|
||||
@@ -385,22 +431,29 @@ class ChatTable:
|
||||
def update_chat_tags_by_id(
|
||||
self, id: str, tags: list[str], user
|
||||
) -> Optional[ChatModel]:
|
||||
chat = self.get_chat_by_id(id)
|
||||
if chat is None:
|
||||
return None
|
||||
with get_db_context() as db:
|
||||
chat = db.get(Chat, id)
|
||||
if chat is None:
|
||||
return None
|
||||
|
||||
self.delete_all_tags_by_id_and_user_id(id, user.id)
|
||||
old_tags = chat.meta.get("tags", [])
|
||||
new_tags = [t for t in tags if t.replace(" ", "_").lower() != "none"]
|
||||
new_tag_ids = [t.replace(" ", "_").lower() for t in new_tags]
|
||||
|
||||
for tag in chat.meta.get("tags", []):
|
||||
if self.count_chats_by_tag_name_and_user_id(tag, user.id) == 0:
|
||||
Tags.delete_tag_by_name_and_user_id(tag, user.id)
|
||||
# Single meta update
|
||||
chat.meta = {**chat.meta, "tags": new_tag_ids}
|
||||
db.commit()
|
||||
db.refresh(chat)
|
||||
|
||||
for tag_name in tags:
|
||||
if tag_name.lower() == "none":
|
||||
continue
|
||||
# Batch-create any missing tag rows
|
||||
Tags.ensure_tags_exist(new_tags, user.id, db=db)
|
||||
|
||||
self.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, tag_name)
|
||||
return self.get_chat_by_id(id)
|
||||
# Clean up orphaned old tags in one query
|
||||
removed = set(old_tags) - set(new_tag_ids)
|
||||
if removed:
|
||||
self.delete_orphan_tags_for_user(list(removed), user.id, db=db)
|
||||
|
||||
return ChatModel.model_validate(chat)
|
||||
|
||||
def get_chat_title_by_id(self, id: str) -> Optional[str]:
|
||||
chat = self.get_chat_by_id(id)
|
||||
@@ -450,6 +503,18 @@ class ChatTable:
|
||||
history["currentId"] = message_id
|
||||
|
||||
chat["history"] = history
|
||||
|
||||
# Dual-write to chat_message table
|
||||
try:
|
||||
ChatMessages.upsert_message(
|
||||
message_id=message_id,
|
||||
chat_id=id,
|
||||
user_id=self.get_chat_by_id(id).user_id,
|
||||
data=history["messages"][message_id],
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to write to chat_message table: {e}")
|
||||
|
||||
return self.update_chat_by_id(id, chat)
|
||||
|
||||
def add_message_status_to_chat_by_id_and_message_id(
|
||||
@@ -563,6 +628,13 @@ class ChatTable:
|
||||
) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
# Use subquery to delete chat_messages for shared chats
|
||||
shared_chat_id_subquery = (
|
||||
db.query(Chat.id).filter_by(user_id=f"shared-{chat_id}").subquery()
|
||||
)
|
||||
db.query(ChatMessage).filter(
|
||||
ChatMessage.chat_id.in_(shared_chat_id_subquery)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete()
|
||||
db.commit()
|
||||
|
||||
@@ -675,6 +747,51 @@ class ChatTable:
|
||||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
def get_shared_chat_list_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
filter: Optional[dict] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[ChatModel]:
|
||||
|
||||
with get_db_context(db) as db:
|
||||
query = (
|
||||
db.query(Chat)
|
||||
.filter_by(user_id=user_id)
|
||||
.filter(Chat.share_id.isnot(None))
|
||||
)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by and direction:
|
||||
if not getattr(Chat, order_by, None):
|
||||
raise ValueError("Invalid order_by field")
|
||||
|
||||
if direction.lower() == "asc":
|
||||
query = query.order_by(getattr(Chat, order_by).asc())
|
||||
elif direction.lower() == "desc":
|
||||
query = query.order_by(getattr(Chat, order_by).desc())
|
||||
else:
|
||||
raise ValueError("Invalid direction for ordering")
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
def get_chat_list_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -1013,29 +1130,23 @@ class ChatTable:
|
||||
|
||||
# Check if there are any tags to filter, it should have all the tags
|
||||
if "none" in tag_ids:
|
||||
query = query.filter(
|
||||
text(
|
||||
"""
|
||||
query = query.filter(text("""
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.meta, '$.tags') AS tag
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
"""))
|
||||
elif tag_ids:
|
||||
query = query.filter(
|
||||
and_(
|
||||
*[
|
||||
text(
|
||||
f"""
|
||||
text(f"""
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.meta, '$.tags') AS tag
|
||||
WHERE tag.value = :tag_id_{tag_idx}
|
||||
)
|
||||
"""
|
||||
).params(**{f"tag_id_{tag_idx}": tag_id})
|
||||
""").params(**{f"tag_id_{tag_idx}": tag_id})
|
||||
for tag_idx, tag_id in enumerate(tag_ids)
|
||||
]
|
||||
)
|
||||
@@ -1071,29 +1182,23 @@ class ChatTable:
|
||||
|
||||
# Check if there are any tags to filter, it should have all the tags
|
||||
if "none" in tag_ids:
|
||||
query = query.filter(
|
||||
text(
|
||||
"""
|
||||
query = query.filter(text("""
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements_text(Chat.meta->'tags') AS tag
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
"""))
|
||||
elif tag_ids:
|
||||
query = query.filter(
|
||||
and_(
|
||||
*[
|
||||
text(
|
||||
f"""
|
||||
text(f"""
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements_text(Chat.meta->'tags') AS tag
|
||||
WHERE tag = :tag_id_{tag_idx}
|
||||
)
|
||||
"""
|
||||
).params(**{f"tag_id_{tag_idx}": tag_id})
|
||||
""").params(**{f"tag_id_{tag_idx}": tag_id})
|
||||
for tag_idx, tag_id in enumerate(tag_ids)
|
||||
]
|
||||
)
|
||||
@@ -1169,8 +1274,8 @@ class ChatTable:
|
||||
) -> list[TagModel]:
|
||||
with get_db_context(db) as db:
|
||||
chat = db.get(Chat, id)
|
||||
tags = chat.meta.get("tags", [])
|
||||
return [Tags.get_tag_by_name_and_user_id(tag, user_id) for tag in tags]
|
||||
tag_ids = chat.meta.get("tags", [])
|
||||
return Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db)
|
||||
|
||||
def get_chat_list_by_user_id_and_tag_name(
|
||||
self,
|
||||
@@ -1211,20 +1316,16 @@ class ChatTable:
|
||||
def add_chat_tag_by_id_and_user_id_and_tag_name(
|
||||
self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None
|
||||
) -> Optional[ChatModel]:
|
||||
tag = Tags.get_tag_by_name_and_user_id(tag_name, user_id)
|
||||
if tag is None:
|
||||
tag = Tags.insert_new_tag(tag_name, user_id)
|
||||
tag_id = tag_name.replace(" ", "_").lower()
|
||||
Tags.ensure_tags_exist([tag_name], user_id, db=db)
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
chat = db.get(Chat, id)
|
||||
|
||||
tag_id = tag.id
|
||||
if tag_id not in chat.meta.get("tags", []):
|
||||
chat.meta = {
|
||||
**chat.meta,
|
||||
"tags": list(set(chat.meta.get("tags", []) + [tag_id])),
|
||||
}
|
||||
|
||||
db.commit()
|
||||
db.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
@@ -1234,40 +1335,53 @@ class ChatTable:
|
||||
def count_chats_by_tag_name_and_user_id(
|
||||
self, tag_name: str, user_id: str, db: Optional[Session] = None
|
||||
) -> int:
|
||||
with get_db_context(db) as db: # Assuming `get_db()` returns a session object
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id, archived=False)
|
||||
|
||||
# Normalize the tag_name for consistency
|
||||
tag_id = tag_name.replace(" ", "_").lower()
|
||||
|
||||
if db.bind.dialect.name == "sqlite":
|
||||
# SQLite JSON1 support for querying the tags inside the `meta` JSON field
|
||||
query = query.filter(
|
||||
text(
|
||||
f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)"
|
||||
"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)"
|
||||
)
|
||||
).params(tag_id=tag_id)
|
||||
|
||||
elif db.bind.dialect.name == "postgresql":
|
||||
# PostgreSQL JSONB support for querying the tags inside the `meta` JSON field
|
||||
query = query.filter(
|
||||
text(
|
||||
"EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)"
|
||||
)
|
||||
).params(tag_id=tag_id)
|
||||
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"Unsupported dialect: {db.bind.dialect.name}"
|
||||
)
|
||||
|
||||
# Get the count of matching records
|
||||
count = query.count()
|
||||
return query.count()
|
||||
|
||||
# Debugging output for inspection
|
||||
log.info(f"Count of chats for tag '{tag_name}': {count}")
|
||||
def delete_orphan_tags_for_user(
|
||||
self,
|
||||
tag_ids: list[str],
|
||||
user_id: str,
|
||||
threshold: int = 0,
|
||||
db: Optional[Session] = None,
|
||||
) -> None:
|
||||
"""Delete tag rows from *tag_ids* that appear in at most *threshold*
|
||||
non-archived chats for *user_id*. One query to find orphans, one to
|
||||
delete them.
|
||||
|
||||
return count
|
||||
Use threshold=0 after a tag is already removed from a chat's meta.
|
||||
Use threshold=1 when the chat itself is about to be deleted (the
|
||||
referencing chat still exists at query time).
|
||||
"""
|
||||
if not tag_ids:
|
||||
return
|
||||
with get_db_context(db) as db:
|
||||
orphans = []
|
||||
for tag_id in tag_ids:
|
||||
count = self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=db)
|
||||
if count <= threshold:
|
||||
orphans.append(tag_id)
|
||||
Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=db)
|
||||
|
||||
def count_chats_by_folder_id_and_user_id(
|
||||
self, folder_id: str, user_id: str, db: Optional[Session] = None
|
||||
@@ -1319,6 +1433,7 @@ class ChatTable:
|
||||
def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(ChatMessage).filter_by(chat_id=id).delete()
|
||||
db.query(Chat).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
||||
@@ -1331,6 +1446,7 @@ class ChatTable:
|
||||
) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(ChatMessage).filter_by(chat_id=id).delete()
|
||||
db.query(Chat).filter_by(id=id, user_id=user_id).delete()
|
||||
db.commit()
|
||||
|
||||
@@ -1345,6 +1461,12 @@ class ChatTable:
|
||||
with get_db_context(db) as db:
|
||||
self.delete_shared_chats_by_user_id(user_id, db=db)
|
||||
|
||||
chat_id_subquery = (
|
||||
db.query(Chat.id).filter_by(user_id=user_id).subquery()
|
||||
)
|
||||
db.query(ChatMessage).filter(
|
||||
ChatMessage.chat_id.in_(chat_id_subquery)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Chat).filter_by(user_id=user_id).delete()
|
||||
db.commit()
|
||||
|
||||
@@ -1357,6 +1479,14 @@ class ChatTable:
|
||||
) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
chat_id_subquery = (
|
||||
db.query(Chat.id)
|
||||
.filter_by(user_id=user_id, folder_id=folder_id)
|
||||
.subquery()
|
||||
)
|
||||
db.query(ChatMessage).filter(
|
||||
ChatMessage.chat_id.in_(chat_id_subquery)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete()
|
||||
db.commit()
|
||||
|
||||
@@ -1390,6 +1520,15 @@ class ChatTable:
|
||||
chats_by_user = db.query(Chat).filter_by(user_id=user_id).all()
|
||||
shared_chat_ids = [f"shared-{chat.id}" for chat in chats_by_user]
|
||||
|
||||
# Use subquery to delete chat_messages for shared chats
|
||||
shared_id_subq = (
|
||||
db.query(Chat.id)
|
||||
.filter(Chat.user_id.in_(shared_chat_ids))
|
||||
.subquery()
|
||||
)
|
||||
db.query(ChatMessage).filter(
|
||||
ChatMessage.chat_id.in_(shared_id_subq)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Chat).filter(Chat.user_id.in_(shared_chat_ids)).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -191,6 +191,23 @@ class FeedbackTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_feedbacks_by_chat_id(
|
||||
self, chat_id: str, db: Optional[Session] = None
|
||||
) -> list[FeedbackModel]:
|
||||
"""Get all feedbacks for a specific chat."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
# meta.chat_id stores the chat reference
|
||||
feedbacks = (
|
||||
db.query(Feedback)
|
||||
.filter(Feedback.meta["chat_id"].as_string() == chat_id)
|
||||
.order_by(Feedback.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [FeedbackModel.model_validate(fb) for fb in feedbacks]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_feedback_items(
|
||||
self,
|
||||
filter: dict = {},
|
||||
@@ -460,23 +477,15 @@ class FeedbackTable:
|
||||
self, user_id: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
feedbacks = db.query(Feedback).filter_by(user_id=user_id).all()
|
||||
if not feedbacks:
|
||||
return False
|
||||
for feedback in feedbacks:
|
||||
db.delete(feedback)
|
||||
result = db.query(Feedback).filter_by(user_id=user_id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
return result > 0
|
||||
|
||||
def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
feedbacks = db.query(Feedback).all()
|
||||
if not feedbacks:
|
||||
return False
|
||||
for feedback in feedbacks:
|
||||
db.delete(feedback)
|
||||
result = db.query(Feedback).delete()
|
||||
db.commit()
|
||||
return True
|
||||
return result > 0
|
||||
|
||||
|
||||
Feedbacks = FeedbackTable()
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -26,8 +26,6 @@ class File(Base):
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
@@ -45,8 +43,6 @@ class FileModel(BaseModel):
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
created_at: Optional[int] # timestamp in epoch
|
||||
updated_at: Optional[int] # timestamp in epoch
|
||||
|
||||
@@ -63,6 +59,25 @@ class FileMeta(BaseModel):
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def sanitize_meta(cls, data):
|
||||
"""Sanitize metadata fields to handle malformed legacy data."""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# Handle content_type that may be a list like ['application/pdf', None]
|
||||
content_type = data.get("content_type")
|
||||
if isinstance(content_type, list):
|
||||
# Extract first non-None string value
|
||||
data["content_type"] = next(
|
||||
(item for item in content_type if isinstance(item, str)), None
|
||||
)
|
||||
elif content_type is not None and not isinstance(content_type, str):
|
||||
data["content_type"] = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class FileModelResponse(BaseModel):
|
||||
id: str
|
||||
@@ -74,7 +89,7 @@ class FileModelResponse(BaseModel):
|
||||
meta: FileMeta
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@@ -94,7 +109,6 @@ class FileForm(BaseModel):
|
||||
path: str
|
||||
data: dict = {}
|
||||
meta: dict = {}
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class FileUpdateForm(BaseModel):
|
||||
|
||||
@@ -11,7 +11,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -195,6 +195,25 @@ class FunctionsTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_functions_by_ids(
|
||||
self, ids: list[str], db: Optional[Session] = None
|
||||
) -> list[FunctionModel]:
|
||||
"""
|
||||
Batch fetch multiple functions by their IDs in a single query.
|
||||
Returns functions in the same order as the input IDs (None entries filtered out).
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
functions = db.query(Function).filter(Function.id.in_(ids)).all()
|
||||
# Create a dict for O(1) lookup
|
||||
func_dict = {f.id: FunctionModel.model_validate(f) for f in functions}
|
||||
# Return in original order, filtering out any not found
|
||||
return [func_dict[id] for id in ids if id in func_dict]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_functions(
|
||||
self, active_only=False, include_valves=False, db: Optional[Session] = None
|
||||
) -> list[FunctionModel | FunctionWithValvesModel]:
|
||||
@@ -299,7 +318,7 @@ class FunctionsTable:
|
||||
function.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(function)
|
||||
return self.get_function_by_id(id, db=db)
|
||||
return FunctionModel.model_validate(function)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -319,7 +338,7 @@ class FunctionsTable:
|
||||
function.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(function)
|
||||
return self.get_function_by_id(id, db=db)
|
||||
return FunctionModel.model_validate(function)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -381,7 +400,8 @@ class FunctionsTable:
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
return self.get_function_by_id(id, db=db)
|
||||
function = db.get(Function, id)
|
||||
return FunctionModel.model_validate(function) if function else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ from sqlalchemy import (
|
||||
ForeignKey,
|
||||
cast,
|
||||
or_,
|
||||
select,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
@@ -99,6 +99,16 @@ class GroupResponse(GroupModel):
|
||||
member_count: Optional[int] = None
|
||||
|
||||
|
||||
class GroupInfoResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str
|
||||
member_count: Optional[int] = None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class GroupForm(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
@@ -154,7 +164,14 @@ class GroupTable:
|
||||
|
||||
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(Group)
|
||||
member_count = (
|
||||
select(func.count(GroupMember.user_id))
|
||||
.where(GroupMember.group_id == Group.id)
|
||||
.correlate(Group)
|
||||
.scalar_subquery()
|
||||
.label("member_count")
|
||||
)
|
||||
query = db.query(Group, member_count)
|
||||
|
||||
if filter:
|
||||
if "query" in filter:
|
||||
@@ -165,27 +182,24 @@ class GroupTable:
|
||||
share_value = filter["share"]
|
||||
member_id = filter.get("member_id")
|
||||
json_share = Group.data["config"]["share"]
|
||||
json_share_bool = json_share.as_boolean()
|
||||
json_share_str = json_share.as_string()
|
||||
json_share_lower = func.lower(json_share_str)
|
||||
|
||||
if share_value:
|
||||
# Groups open to anyone: data is null, share is null, or share is true
|
||||
anyone_can_share = or_(
|
||||
Group.data.is_(None),
|
||||
json_share_bool.is_(None),
|
||||
json_share_bool == True,
|
||||
json_share_str.is_(None),
|
||||
json_share_lower == "true",
|
||||
json_share_lower == "1", # Handle SQLite boolean true
|
||||
)
|
||||
|
||||
if member_id:
|
||||
# Also include member-only groups where user is a member
|
||||
member_groups_subq = (
|
||||
db.query(GroupMember.group_id)
|
||||
.filter(GroupMember.user_id == member_id)
|
||||
.subquery()
|
||||
member_groups_select = select(GroupMember.group_id).where(
|
||||
GroupMember.user_id == member_id
|
||||
)
|
||||
members_only_and_is_member = and_(
|
||||
json_share_str == "members",
|
||||
Group.id.in_(member_groups_subq),
|
||||
json_share_lower == "members",
|
||||
Group.id.in_(member_groups_select),
|
||||
)
|
||||
query = query.filter(
|
||||
or_(anyone_can_share, members_only_and_is_member)
|
||||
@@ -194,27 +208,30 @@ class GroupTable:
|
||||
query = query.filter(anyone_can_share)
|
||||
else:
|
||||
query = query.filter(
|
||||
and_(Group.data.isnot(None), json_share_bool == False)
|
||||
and_(Group.data.isnot(None), json_share_lower == "false")
|
||||
)
|
||||
|
||||
else:
|
||||
# Only apply member_id filter when share filter is NOT present
|
||||
if "member_id" in filter:
|
||||
query = query.join(
|
||||
GroupMember, GroupMember.group_id == Group.id
|
||||
).filter(GroupMember.user_id == filter["member_id"])
|
||||
query = query.filter(
|
||||
Group.id.in_(
|
||||
select(GroupMember.group_id).where(
|
||||
GroupMember.user_id == filter["member_id"]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
results = query.order_by(Group.updated_at.desc()).all()
|
||||
|
||||
groups = query.order_by(Group.updated_at.desc()).all()
|
||||
return [
|
||||
GroupResponse.model_validate(
|
||||
{
|
||||
**GroupModel.model_validate(group).model_dump(),
|
||||
"member_count": self.get_group_member_count_by_id(
|
||||
group.id, db=db
|
||||
),
|
||||
"member_count": count or 0,
|
||||
}
|
||||
)
|
||||
for group in groups
|
||||
for group, count in results
|
||||
]
|
||||
|
||||
def search_groups(
|
||||
@@ -231,29 +248,46 @@ class GroupTable:
|
||||
if "query" in filter:
|
||||
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
|
||||
if "member_id" in filter:
|
||||
query = query.join(
|
||||
GroupMember, GroupMember.group_id == Group.id
|
||||
).filter(GroupMember.user_id == filter["member_id"])
|
||||
query = query.filter(
|
||||
Group.id.in_(
|
||||
select(GroupMember.group_id).where(
|
||||
GroupMember.user_id == filter["member_id"]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if "share" in filter:
|
||||
# 'share' is stored in data JSON, support both sqlite and postgres
|
||||
share_value = filter["share"]
|
||||
print("Filtering by share:", share_value)
|
||||
query = query.filter(
|
||||
Group.data.op("->>")("share") == str(share_value)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
query = query.order_by(Group.updated_at.desc())
|
||||
groups = query.offset(skip).limit(limit).all()
|
||||
|
||||
member_count = (
|
||||
select(func.count(GroupMember.user_id))
|
||||
.where(GroupMember.group_id == Group.id)
|
||||
.correlate(Group)
|
||||
.scalar_subquery()
|
||||
.label("member_count")
|
||||
)
|
||||
results = (
|
||||
query.add_columns(member_count)
|
||||
.order_by(Group.updated_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"items": [
|
||||
GroupResponse.model_validate(
|
||||
**GroupModel.model_validate(group).model_dump(),
|
||||
member_count=self.get_group_member_count_by_id(group.id, db=db),
|
||||
{
|
||||
**GroupModel.model_validate(group).model_dump(),
|
||||
"member_count": count or 0,
|
||||
}
|
||||
)
|
||||
for group in groups
|
||||
for group, count in results
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
@@ -304,14 +338,14 @@ class GroupTable:
|
||||
|
||||
def get_group_user_ids_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[list[str]]:
|
||||
) -> list[str]:
|
||||
with get_db_context(db) as db:
|
||||
members = (
|
||||
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
|
||||
)
|
||||
|
||||
if not members:
|
||||
return None
|
||||
return []
|
||||
|
||||
return [m[0] for m in members]
|
||||
|
||||
@@ -368,6 +402,20 @@ class GroupTable:
|
||||
)
|
||||
return count if count else 0
|
||||
|
||||
def get_group_member_counts_by_ids(
|
||||
self, ids: list[str], db: Optional[Session] = None
|
||||
) -> dict[str, int]:
|
||||
if not ids:
|
||||
return {}
|
||||
with get_db_context(db) as db:
|
||||
rows = (
|
||||
db.query(GroupMember.group_id, func.count(GroupMember.user_id))
|
||||
.filter(GroupMember.group_id.in_(ids))
|
||||
.group_by(GroupMember.group_id)
|
||||
.all()
|
||||
)
|
||||
return {group_id: count for group_id, count in rows}
|
||||
|
||||
def update_group_by_id(
|
||||
self,
|
||||
id: str,
|
||||
@@ -588,11 +636,10 @@ class GroupTable:
|
||||
if not user_ids:
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
# Remove each user from group_member
|
||||
for user_id in user_ids:
|
||||
db.query(GroupMember).filter(
|
||||
GroupMember.group_id == id, GroupMember.user_id == user_id
|
||||
).delete()
|
||||
# Remove users from group_member in batch
|
||||
db.query(GroupMember).filter(
|
||||
GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Update group timestamp
|
||||
group.updated_at = int(time.time())
|
||||
|
||||
@@ -15,9 +15,10 @@ from open_webui.models.files import (
|
||||
)
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Column,
|
||||
@@ -29,10 +30,6 @@ from sqlalchemy import (
|
||||
or_,
|
||||
)
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.db.access_control import has_permission
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
@@ -50,22 +47,6 @@ class Knowledge(Base):
|
||||
description = Column(Text)
|
||||
|
||||
meta = Column(JSON, nullable=True)
|
||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||
# Defines access control rules for this entry.
|
||||
# - `None`: Public access, available to all users with the "user" role.
|
||||
# - `{}`: Private access, restricted exclusively to the owner.
|
||||
# - Custom permissions: Specific access control for reading and writing;
|
||||
# Can specify group or user-level restrictions:
|
||||
# {
|
||||
# "read": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# },
|
||||
# "write": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# }
|
||||
# }
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
@@ -82,7 +63,7 @@ class KnowledgeModel(BaseModel):
|
||||
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
@@ -139,7 +120,7 @@ class KnowledgeUserResponse(KnowledgeUserModel):
|
||||
class KnowledgeForm(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class FileUserResponse(FileModelResponse):
|
||||
@@ -157,27 +138,47 @@ class KnowledgeFileListResponse(BaseModel):
|
||||
|
||||
|
||||
class KnowledgeTable:
|
||||
def _get_access_grants(
|
||||
self, knowledge_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
|
||||
|
||||
def _to_knowledge_model(
|
||||
self, knowledge: Knowledge, db: Optional[Session] = None
|
||||
) -> KnowledgeModel:
|
||||
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
knowledge_data["access_grants"] = self._get_access_grants(
|
||||
knowledge_data["id"], db=db
|
||||
)
|
||||
return KnowledgeModel.model_validate(knowledge_data)
|
||||
|
||||
def insert_new_knowledge(
|
||||
self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None
|
||||
) -> Optional[KnowledgeModel]:
|
||||
with get_db_context(db) as db:
|
||||
knowledge = KnowledgeModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
"access_grants": [],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
result = Knowledge(**knowledge.model_dump())
|
||||
result = Knowledge(**knowledge.model_dump(exclude={"access_grants"}))
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"knowledge", result.id, form_data.access_grants, db=db
|
||||
)
|
||||
if result:
|
||||
return KnowledgeModel.model_validate(result)
|
||||
return self._to_knowledge_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
@@ -201,7 +202,7 @@ class KnowledgeTable:
|
||||
knowledge_bases.append(
|
||||
KnowledgeUserModel.model_validate(
|
||||
{
|
||||
**KnowledgeModel.model_validate(knowledge).model_dump(),
|
||||
**self._to_knowledge_model(knowledge, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -229,6 +230,9 @@ class KnowledgeTable:
|
||||
or_(
|
||||
Knowledge.name.ilike(f"%{query_key}%"),
|
||||
Knowledge.description.ilike(f"%{query_key}%"),
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
User.username.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -238,9 +242,16 @@ class KnowledgeTable:
|
||||
elif view_option == "shared":
|
||||
query = query.filter(Knowledge.user_id != user_id)
|
||||
|
||||
query = has_permission(db, Knowledge, query, filter)
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Knowledge,
|
||||
filter=filter,
|
||||
resource_type="knowledge",
|
||||
permission="read",
|
||||
)
|
||||
|
||||
query = query.order_by(Knowledge.updated_at.desc())
|
||||
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
|
||||
|
||||
total = query.count()
|
||||
if skip:
|
||||
@@ -255,8 +266,8 @@ class KnowledgeTable:
|
||||
knowledge_bases.append(
|
||||
KnowledgeUserModel.model_validate(
|
||||
{
|
||||
**KnowledgeModel.model_validate(
|
||||
knowledge_base
|
||||
**self._to_knowledge_model(
|
||||
knowledge_base, db=db
|
||||
).model_dump(),
|
||||
"user": (
|
||||
UserModel.model_validate(user).model_dump()
|
||||
@@ -291,7 +302,14 @@ class KnowledgeTable:
|
||||
|
||||
# Apply access-control directly to the joined query
|
||||
# This makes the database handle filtering, even with 10k+ KBs
|
||||
query = has_permission(db, Knowledge, query, filter)
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Knowledge,
|
||||
filter=filter,
|
||||
resource_type="knowledge",
|
||||
permission="read",
|
||||
)
|
||||
|
||||
# Apply filename search
|
||||
if filter:
|
||||
@@ -300,7 +318,7 @@ class KnowledgeTable:
|
||||
query = query.filter(File.filename.ilike(f"%{q}%"))
|
||||
|
||||
# Order by file changes
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
query = query.order_by(File.updated_at.desc(), File.id.asc())
|
||||
|
||||
# Count before pagination
|
||||
total = query.count()
|
||||
@@ -324,8 +342,8 @@ class KnowledgeTable:
|
||||
if user
|
||||
else None
|
||||
),
|
||||
collection=KnowledgeModel.model_validate(
|
||||
knowledge
|
||||
collection=self._to_knowledge_model(
|
||||
knowledge, db=db
|
||||
).model_dump(),
|
||||
)
|
||||
)
|
||||
@@ -347,7 +365,14 @@ class KnowledgeTable:
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||
}
|
||||
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
|
||||
return AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
def get_knowledge_bases_by_user_id(
|
||||
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||
@@ -360,8 +385,13 @@ class KnowledgeTable:
|
||||
knowledge_base
|
||||
for knowledge_base in knowledge_bases
|
||||
if knowledge_base.user_id == user_id
|
||||
or has_access(
|
||||
user_id, permission, knowledge_base.access_control, user_group_ids
|
||||
or 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,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -371,7 +401,7 @@ class KnowledgeTable:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
knowledge = db.query(Knowledge).filter_by(id=id).first()
|
||||
return KnowledgeModel.model_validate(knowledge) if knowledge else None
|
||||
return self._to_knowledge_model(knowledge, db=db) if knowledge else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -388,7 +418,14 @@ class KnowledgeTable:
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||
}
|
||||
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
|
||||
if AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
return knowledge
|
||||
return None
|
||||
|
||||
@@ -404,7 +441,8 @@ class KnowledgeTable:
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
KnowledgeModel.model_validate(knowledge) for knowledge in knowledges
|
||||
self._to_knowledge_model(knowledge, db=db)
|
||||
for knowledge in knowledges
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
@@ -427,6 +465,9 @@ class KnowledgeTable:
|
||||
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||
)
|
||||
|
||||
# Default sort: updated_at descending
|
||||
primary_sort = File.updated_at.desc()
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
@@ -440,27 +481,23 @@ class KnowledgeTable:
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
is_asc = direction == "asc"
|
||||
|
||||
if order_by == "name":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.filename.asc())
|
||||
else:
|
||||
query = query.order_by(File.filename.desc())
|
||||
primary_sort = (
|
||||
File.filename.asc() if is_asc else File.filename.desc()
|
||||
)
|
||||
elif order_by == "created_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(File.created_at.desc())
|
||||
primary_sort = (
|
||||
File.created_at.asc() if is_asc else File.created_at.desc()
|
||||
)
|
||||
elif order_by == "updated_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(File.updated_at.asc())
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
primary_sort = (
|
||||
File.updated_at.asc() if is_asc else File.updated_at.desc()
|
||||
)
|
||||
|
||||
else:
|
||||
query = query.order_by(File.updated_at.desc())
|
||||
# Apply sort with secondary key for deterministic pagination
|
||||
query = query.order_by(primary_sort, File.id.asc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
@@ -595,11 +632,15 @@ class KnowledgeTable:
|
||||
knowledge = self.get_knowledge_by_id(id=id, db=db)
|
||||
db.query(Knowledge).filter_by(id=id).update(
|
||||
{
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
if form_data.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"knowledge", id, form_data.access_grants, db=db
|
||||
)
|
||||
return self.get_knowledge_by_id(id=id, db=db)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -626,6 +667,7 @@ class KnowledgeTable:
|
||||
def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("knowledge", id, db=db)
|
||||
db.query(Knowledge).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
@@ -635,6 +677,9 @@ class KnowledgeTable:
|
||||
def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()]
|
||||
for knowledge_id in knowledge_ids:
|
||||
AccessGrants.revoke_all_access("knowledge", knowledge_id, db=db)
|
||||
db.query(Knowledge).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ class MemoriesTable:
|
||||
memory.updated_at = int(time.time())
|
||||
|
||||
db.commit()
|
||||
return self.get_memory_by_id(id)
|
||||
db.refresh(memory)
|
||||
return MemoryModel.model_validate(memory)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -7,19 +7,16 @@ from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from sqlalchemy import String, cast, or_, and_, func
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
||||
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
from sqlalchemy import BigInteger, Column, Text, Boolean
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,23 +77,6 @@ class Model(Base):
|
||||
Holds a JSON encoded blob of metadata, see `ModelMeta`.
|
||||
"""
|
||||
|
||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||
# Defines access control rules for this entry.
|
||||
# - `None`: Public access, available to all users with the "user" role.
|
||||
# - `{}`: Private access, restricted exclusively to the owner.
|
||||
# - Custom permissions: Specific access control for reading and writing;
|
||||
# Can specify group or user-level restrictions:
|
||||
# {
|
||||
# "read": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# },
|
||||
# "write": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# }
|
||||
# }
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
updated_at = Column(BigInteger)
|
||||
@@ -112,7 +92,7 @@ class ModelModel(BaseModel):
|
||||
params: ModelParams
|
||||
meta: ModelMeta
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
is_active: bool
|
||||
updated_at: int # timestamp in epoch
|
||||
@@ -154,31 +134,45 @@ class ModelForm(BaseModel):
|
||||
name: str
|
||||
meta: ModelMeta
|
||||
params: ModelParams
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ModelsTable:
|
||||
def _get_access_grants(
|
||||
self, model_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
|
||||
|
||||
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
|
||||
model_data = ModelModel.model_validate(model).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
|
||||
return ModelModel.model_validate(model_data)
|
||||
|
||||
def insert_new_model(
|
||||
self, form_data: ModelForm, user_id: str, db: Optional[Session] = None
|
||||
) -> Optional[ModelModel]:
|
||||
model = ModelModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
result = Model(**model.model_dump())
|
||||
result = Model(
|
||||
**{
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"model", result.id, form_data.access_grants, db=db
|
||||
)
|
||||
|
||||
if result:
|
||||
return ModelModel.model_validate(result)
|
||||
return self._to_model_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -187,7 +181,9 @@ class ModelsTable:
|
||||
|
||||
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
return [ModelModel.model_validate(model) for model in db.query(Model).all()]
|
||||
return [
|
||||
self._to_model_model(model, db=db) for model in db.query(Model).all()
|
||||
]
|
||||
|
||||
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
|
||||
with get_db_context(db) as db:
|
||||
@@ -204,7 +200,7 @@ class ModelsTable:
|
||||
models.append(
|
||||
ModelUserResponse.model_validate(
|
||||
{
|
||||
**ModelModel.model_validate(model).model_dump(),
|
||||
**self._to_model_model(model, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -214,7 +210,7 @@ class ModelsTable:
|
||||
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
return [
|
||||
ModelModel.model_validate(model)
|
||||
self._to_model_model(model, db=db)
|
||||
for model in db.query(Model).filter(Model.base_model_id == None).all()
|
||||
]
|
||||
|
||||
@@ -229,50 +225,25 @@ class ModelsTable:
|
||||
model
|
||||
for model in models
|
||||
if model.user_id == user_id
|
||||
or has_access(user_id, permission, model.access_control, user_group_ids)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
# Public access
|
||||
conditions = []
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
Model.access_control.is_(None),
|
||||
cast(Model.access_control, String) == "null",
|
||||
]
|
||||
)
|
||||
|
||||
# User-level permission
|
||||
if user_id:
|
||||
conditions.append(Model.user_id == user_id)
|
||||
|
||||
# Group-level permission
|
||||
if group_ids:
|
||||
group_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_conditions.append(
|
||||
Model.access_control[permission]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_conditions.append(
|
||||
cast(
|
||||
Model.access_control[permission]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
conditions.append(or_(*group_conditions))
|
||||
|
||||
if conditions:
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query
|
||||
return AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Model,
|
||||
filter=filter,
|
||||
resource_type="model",
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
def search_models(
|
||||
self,
|
||||
@@ -294,6 +265,9 @@ class ModelsTable:
|
||||
or_(
|
||||
Model.name.ilike(f"%{query_key}%"),
|
||||
Model.base_model_id.ilike(f"%{query_key}%"),
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
User.username.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -355,7 +329,7 @@ class ModelsTable:
|
||||
for model, user in items:
|
||||
models.append(
|
||||
ModelUserResponse(
|
||||
**ModelModel.model_validate(model).model_dump(),
|
||||
**self._to_model_model(model, db=db).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
@@ -372,7 +346,7 @@ class ModelsTable:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
model = db.get(Model, id)
|
||||
return ModelModel.model_validate(model)
|
||||
return self._to_model_model(model, db=db) if model else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -382,7 +356,7 @@ class ModelsTable:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
models = db.query(Model).filter(Model.id.in_(ids)).all()
|
||||
return [ModelModel.model_validate(model) for model in models]
|
||||
return [self._to_model_model(model, db=db) for model in models]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -391,17 +365,16 @@ class ModelsTable:
|
||||
) -> Optional[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
is_active = db.query(Model).filter_by(id=id).first().is_active
|
||||
model = db.query(Model).filter_by(id=id).first()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
db.query(Model).filter_by(id=id).update(
|
||||
{
|
||||
"is_active": not is_active,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
model.is_active = not model.is_active
|
||||
model.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
|
||||
return self.get_model_by_id(id, db=db)
|
||||
return self._to_model_model(model, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -411,14 +384,16 @@ class ModelsTable:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
# update only the fields that are present in the model
|
||||
data = model.model_dump(exclude={"id"})
|
||||
data = model.model_dump(exclude={"id", "access_grants"})
|
||||
result = db.query(Model).filter_by(id=id).update(data)
|
||||
|
||||
db.commit()
|
||||
if model.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"model", id, model.access_grants, db=db
|
||||
)
|
||||
|
||||
model = db.get(Model, id)
|
||||
db.refresh(model)
|
||||
return ModelModel.model_validate(model)
|
||||
return self.get_model_by_id(id, db=db)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to update the model by id {id}: {e}")
|
||||
return None
|
||||
@@ -426,6 +401,7 @@ class ModelsTable:
|
||||
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("model", id, db=db)
|
||||
db.query(Model).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
||||
@@ -436,6 +412,9 @@ class ModelsTable:
|
||||
def delete_all_models(self, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
model_ids = [row[0] for row in db.query(Model.id).all()]
|
||||
for model_id in model_ids:
|
||||
AccessGrants.revoke_all_access("model", model_id, db=db)
|
||||
db.query(Model).delete()
|
||||
db.commit()
|
||||
|
||||
@@ -460,7 +439,7 @@ class ModelsTable:
|
||||
if model.id in existing_ids:
|
||||
db.query(Model).filter_by(id=model.id).update(
|
||||
{
|
||||
**model.model_dump(),
|
||||
**model.model_dump(exclude={"access_grants"}),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
@@ -468,22 +447,27 @@ class ModelsTable:
|
||||
else:
|
||||
new_model = Model(
|
||||
**{
|
||||
**model.model_dump(),
|
||||
**model.model_dump(exclude={"access_grants"}),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(new_model)
|
||||
AccessGrants.set_access_grants(
|
||||
"model", model.id, model.access_grants, db=db
|
||||
)
|
||||
|
||||
# Remove models that are no longer present
|
||||
for model in existing_models:
|
||||
if model.id not in new_model_ids:
|
||||
AccessGrants.revoke_all_access("model", model.id, db=db)
|
||||
db.delete(model)
|
||||
|
||||
db.commit()
|
||||
|
||||
return [
|
||||
ModelModel.model_validate(model) for model in db.query(Model).all()
|
||||
self._to_model_model(model, db=db)
|
||||
for model in db.query(Model).all()
|
||||
]
|
||||
except Exception as e:
|
||||
log.exception(f"Error syncing models for user {user_id}: {e}")
|
||||
|
||||
@@ -7,17 +7,13 @@ from functools import lru_cache
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db, get_db_context
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
|
||||
from sqlalchemy.sql import exists
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON
|
||||
from sqlalchemy import or_, func, cast
|
||||
|
||||
####################
|
||||
# Note DB Schema
|
||||
@@ -34,8 +30,6 @@ class Note(Base):
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
@@ -50,7 +44,7 @@ class NoteModel(BaseModel):
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
@@ -65,14 +59,14 @@ class NoteForm(BaseModel):
|
||||
title: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class NoteUpdateForm(BaseModel):
|
||||
title: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class NoteUserResponse(NoteModel):
|
||||
@@ -94,122 +88,25 @@ class NoteListResponse(BaseModel):
|
||||
|
||||
|
||||
class NoteTable:
|
||||
def _get_access_grants(
|
||||
self, note_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
|
||||
|
||||
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
|
||||
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
|
||||
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
|
||||
return NoteModel.model_validate(note_data)
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
conditions = []
|
||||
|
||||
# Handle read_only permission separately
|
||||
if permission == "read_only":
|
||||
# For read_only, we want items where:
|
||||
# 1. User has explicit read permission (via groups or user-level)
|
||||
# 2. BUT does NOT have write permission
|
||||
# 3. Public items are NOT considered read_only
|
||||
|
||||
read_conditions = []
|
||||
|
||||
# Group-level read permission
|
||||
if group_ids:
|
||||
group_read_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_read_conditions.append(
|
||||
Note.access_control["read"]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_read_conditions.append(
|
||||
cast(
|
||||
Note.access_control["read"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_read_conditions:
|
||||
read_conditions.append(or_(*group_read_conditions))
|
||||
|
||||
# Combine read conditions
|
||||
if read_conditions:
|
||||
has_read = or_(*read_conditions)
|
||||
else:
|
||||
# If no read conditions, return empty result
|
||||
return query.filter(False)
|
||||
|
||||
# Now exclude items where user has write permission
|
||||
write_exclusions = []
|
||||
|
||||
# Exclude items owned by user (they have implicit write)
|
||||
if user_id:
|
||||
write_exclusions.append(Note.user_id != user_id)
|
||||
|
||||
# Exclude items where user has explicit write permission via groups
|
||||
if group_ids:
|
||||
group_write_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_write_conditions.append(
|
||||
Note.access_control["write"]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_write_conditions.append(
|
||||
cast(
|
||||
Note.access_control["write"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_write_conditions:
|
||||
# User should NOT have write permission
|
||||
write_exclusions.append(~or_(*group_write_conditions))
|
||||
|
||||
# Exclude public items (items without access_control)
|
||||
write_exclusions.append(Note.access_control.isnot(None))
|
||||
write_exclusions.append(cast(Note.access_control, String) != "null")
|
||||
|
||||
# Combine: has read AND does not have write AND not public
|
||||
if write_exclusions:
|
||||
query = query.filter(and_(has_read, *write_exclusions))
|
||||
else:
|
||||
query = query.filter(has_read)
|
||||
|
||||
return query
|
||||
|
||||
# Original logic for other permissions (read, write, etc.)
|
||||
# Public access conditions
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
Note.access_control.is_(None),
|
||||
cast(Note.access_control, String) == "null",
|
||||
]
|
||||
)
|
||||
|
||||
# User-level permission (owner has all permissions)
|
||||
if user_id:
|
||||
conditions.append(Note.user_id == user_id)
|
||||
|
||||
# Group-level permission
|
||||
if group_ids:
|
||||
group_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_conditions.append(
|
||||
Note.access_control[permission]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_conditions.append(
|
||||
cast(
|
||||
Note.access_control[permission]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
conditions.append(or_(*group_conditions))
|
||||
|
||||
if conditions:
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query
|
||||
return AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Note,
|
||||
filter=filter,
|
||||
resource_type="note",
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
def insert_new_note(
|
||||
self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
|
||||
@@ -219,17 +116,21 @@ class NoteTable:
|
||||
**{
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"created_at": int(time.time_ns()),
|
||||
"updated_at": int(time.time_ns()),
|
||||
"access_grants": [],
|
||||
}
|
||||
)
|
||||
|
||||
new_note = Note(**note.model_dump())
|
||||
new_note = Note(**note.model_dump(exclude={"access_grants"}))
|
||||
|
||||
db.add(new_note)
|
||||
db.commit()
|
||||
return note
|
||||
AccessGrants.set_access_grants(
|
||||
"note", note.id, form_data.access_grants, db=db
|
||||
)
|
||||
return self._to_note_model(new_note, db=db)
|
||||
|
||||
def get_notes(
|
||||
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
|
||||
@@ -241,7 +142,7 @@ class NoteTable:
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
return [self._to_note_model(note, db=db) for note in notes]
|
||||
|
||||
def search_notes(
|
||||
self,
|
||||
@@ -330,7 +231,7 @@ class NoteTable:
|
||||
for note, user in items:
|
||||
notes.append(
|
||||
NoteUserResponse(
|
||||
**NoteModel.model_validate(note).model_dump(),
|
||||
**self._to_note_model(note, db=db).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
@@ -365,14 +266,14 @@ class NoteTable:
|
||||
query = query.limit(limit)
|
||||
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
return [self._to_note_model(note, db=db) for note in notes]
|
||||
|
||||
def get_note_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[NoteModel]:
|
||||
with get_db_context(db) as db:
|
||||
note = db.query(Note).filter(Note.id == id).first()
|
||||
return NoteModel.model_validate(note) if note else None
|
||||
return self._to_note_model(note, db=db) if note else None
|
||||
|
||||
def update_note_by_id(
|
||||
self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
|
||||
@@ -391,17 +292,20 @@ class NoteTable:
|
||||
if "meta" in form_data:
|
||||
note.meta = {**note.meta, **form_data["meta"]}
|
||||
|
||||
if "access_control" in form_data:
|
||||
note.access_control = form_data["access_control"]
|
||||
if "access_grants" in form_data:
|
||||
AccessGrants.set_access_grants(
|
||||
"note", id, form_data["access_grants"], db=db
|
||||
)
|
||||
|
||||
note.updated_at = int(time.time_ns())
|
||||
|
||||
db.commit()
|
||||
return NoteModel.model_validate(note) if note else None
|
||||
return self._to_note_model(note, db=db) if note else None
|
||||
|
||||
def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("note", id, db=db)
|
||||
db.query(Note).filter(Note.id == id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -102,7 +102,7 @@ class OAuthSessionTable:
|
||||
decrypted = self.fernet.decrypt(token.encode()).decode()
|
||||
return json.loads(decrypted)
|
||||
except Exception as e:
|
||||
log.error(f"Error decrypting tokens: {e}")
|
||||
log.error(f"Error decrypting tokens: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
def create_session(
|
||||
@@ -209,8 +209,15 @@ class OAuthSessionTable:
|
||||
|
||||
results = []
|
||||
for session in sessions:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
results.append(OAuthSessionModel.model_validate(session))
|
||||
try:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
results.append(OAuthSessionModel.model_validate(session))
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f"Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}"
|
||||
)
|
||||
db.query(OAuthSession).filter_by(id=session.id).delete()
|
||||
db.commit()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Prompt history model for version tracking."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
import json
|
||||
import difflib
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db_context
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, Index
|
||||
|
||||
####################
|
||||
# PromptHistory DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class PromptHistory(Base):
|
||||
__tablename__ = "prompt_history"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
prompt_id = Column(Text, nullable=False, index=True)
|
||||
parent_id = Column(Text, nullable=True) # Reference to parent commit
|
||||
snapshot = Column(JSON, nullable=False)
|
||||
user_id = Column(Text, nullable=False)
|
||||
commit_message = Column(Text, nullable=True)
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class PromptHistoryModel(BaseModel):
|
||||
id: str
|
||||
prompt_id: str
|
||||
parent_id: Optional[str] = None
|
||||
snapshot: dict
|
||||
user_id: str
|
||||
commit_message: Optional[str] = None
|
||||
created_at: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PromptHistoryResponse(PromptHistoryModel):
|
||||
"""Response model with user info."""
|
||||
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class PromptHistoryTable:
|
||||
def create_history_entry(
|
||||
self,
|
||||
prompt_id: str,
|
||||
snapshot: dict,
|
||||
user_id: str,
|
||||
parent_id: Optional[str] = None,
|
||||
commit_message: Optional[str] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[PromptHistoryModel]:
|
||||
"""Create a new history entry (commit) for a prompt."""
|
||||
with get_db_context(db) as db:
|
||||
history = PromptHistory(
|
||||
id=str(uuid.uuid4()),
|
||||
prompt_id=prompt_id,
|
||||
parent_id=parent_id,
|
||||
snapshot=snapshot,
|
||||
user_id=user_id,
|
||||
commit_message=commit_message,
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(history)
|
||||
db.commit()
|
||||
db.refresh(history)
|
||||
return PromptHistoryModel.model_validate(history)
|
||||
|
||||
def get_history_by_prompt_id(
|
||||
self,
|
||||
prompt_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[PromptHistoryResponse]:
|
||||
"""Get all history entries for a prompt, ordered by created_at desc."""
|
||||
with get_db_context(db) as db:
|
||||
entries = (
|
||||
db.query(PromptHistory)
|
||||
.filter(PromptHistory.prompt_id == prompt_id)
|
||||
.order_by(PromptHistory.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get user info for each entry
|
||||
user_ids = list(set(e.user_id for e in entries))
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
return [
|
||||
PromptHistoryResponse(
|
||||
**PromptHistoryModel.model_validate(entry).model_dump(),
|
||||
user=(
|
||||
users_dict.get(entry.user_id).model_dump()
|
||||
if users_dict.get(entry.user_id)
|
||||
else None
|
||||
),
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
def get_history_entry_by_id(
|
||||
self,
|
||||
history_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[PromptHistoryModel]:
|
||||
"""Get a specific history entry by ID."""
|
||||
with get_db_context(db) as db:
|
||||
entry = (
|
||||
db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
|
||||
)
|
||||
if entry:
|
||||
return PromptHistoryModel.model_validate(entry)
|
||||
return None
|
||||
|
||||
def get_latest_history_entry(
|
||||
self,
|
||||
prompt_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[PromptHistoryModel]:
|
||||
"""Get the most recent history entry for a prompt."""
|
||||
with get_db_context(db) as db:
|
||||
entry = (
|
||||
db.query(PromptHistory)
|
||||
.filter(PromptHistory.prompt_id == prompt_id)
|
||||
.order_by(PromptHistory.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if entry:
|
||||
return PromptHistoryModel.model_validate(entry)
|
||||
return None
|
||||
|
||||
def get_history_count(
|
||||
self,
|
||||
prompt_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> int:
|
||||
"""Get the number of history entries for a prompt."""
|
||||
with get_db_context(db) as db:
|
||||
return (
|
||||
db.query(PromptHistory)
|
||||
.filter(PromptHistory.prompt_id == prompt_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
def compute_diff(
|
||||
self,
|
||||
from_id: str,
|
||||
to_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Compute diff between two history entries."""
|
||||
with get_db_context(db) as db:
|
||||
from_entry = (
|
||||
db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
|
||||
)
|
||||
to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first()
|
||||
|
||||
if not from_entry or not to_entry:
|
||||
return None
|
||||
|
||||
from_snapshot = from_entry.snapshot
|
||||
to_snapshot = to_entry.snapshot
|
||||
|
||||
# Compute diff for content field
|
||||
from_content = from_snapshot.get("content", "")
|
||||
to_content = to_snapshot.get("content", "")
|
||||
|
||||
diff_lines = list(
|
||||
difflib.unified_diff(
|
||||
from_content.splitlines(keepends=True),
|
||||
to_content.splitlines(keepends=True),
|
||||
fromfile=f"v{from_id[:8]}",
|
||||
tofile=f"v{to_id[:8]}",
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"from_id": from_id,
|
||||
"to_id": to_id,
|
||||
"from_snapshot": from_snapshot,
|
||||
"to_snapshot": to_snapshot,
|
||||
"content_diff": diff_lines,
|
||||
"name_changed": from_snapshot.get("name") != to_snapshot.get("name"),
|
||||
}
|
||||
|
||||
def delete_history_by_prompt_id(
|
||||
self,
|
||||
prompt_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
"""Delete all history entries for a prompt."""
|
||||
with get_db_context(db) as db:
|
||||
db.query(PromptHistory).filter(
|
||||
PromptHistory.prompt_id == prompt_id
|
||||
).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def delete_history_entry(
|
||||
self,
|
||||
history_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
"""Delete a history entry and reparent its children to grandparent."""
|
||||
with get_db_context(db) as db:
|
||||
entry = db.query(PromptHistory).filter_by(id=history_id).first()
|
||||
if not entry:
|
||||
return False
|
||||
|
||||
# Find children that reference this entry as parent
|
||||
children = db.query(PromptHistory).filter_by(parent_id=history_id).all()
|
||||
|
||||
# Reparent children to grandparent
|
||||
for child in children:
|
||||
child.parent_id = entry.parent_id
|
||||
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
PromptHistories = PromptHistoryTable()
|
||||
@@ -1,15 +1,17 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.prompt_history import PromptHistories
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
|
||||
|
||||
####################
|
||||
# Prompts DB Schema
|
||||
@@ -19,38 +21,35 @@ from open_webui.utils.access_control import has_access
|
||||
class Prompt(Base):
|
||||
__tablename__ = "prompt"
|
||||
|
||||
command = Column(String, primary_key=True)
|
||||
id = Column(Text, primary_key=True)
|
||||
command = Column(String, unique=True, index=True)
|
||||
user_id = Column(String)
|
||||
title = Column(Text)
|
||||
name = Column(Text)
|
||||
content = Column(Text)
|
||||
timestamp = Column(BigInteger)
|
||||
|
||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||
# Defines access control rules for this entry.
|
||||
# - `None`: Public access, available to all users with the "user" role.
|
||||
# - `{}`: Private access, restricted exclusively to the owner.
|
||||
# - Custom permissions: Specific access control for reading and writing;
|
||||
# Can specify group or user-level restrictions:
|
||||
# {
|
||||
# "read": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# },
|
||||
# "write": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# }
|
||||
# }
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
tags = Column(JSON, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
version_id = Column(Text, nullable=True) # Points to active history entry
|
||||
created_at = Column(BigInteger, nullable=True)
|
||||
updated_at = Column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
class PromptModel(BaseModel):
|
||||
id: Optional[str] = None
|
||||
command: str
|
||||
user_id: str
|
||||
title: str
|
||||
name: str
|
||||
content: str
|
||||
timestamp: int # timestamp in epoch
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
tags: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = True
|
||||
version_id: Optional[str] = None
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -67,51 +66,144 @@ class PromptAccessResponse(PromptUserResponse):
|
||||
write_access: Optional[bool] = False
|
||||
|
||||
|
||||
class PromptListResponse(BaseModel):
|
||||
items: list[PromptUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PromptAccessListResponse(BaseModel):
|
||||
items: list[PromptAccessResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PromptForm(BaseModel):
|
||||
|
||||
command: str
|
||||
title: str
|
||||
name: str # Changed from title
|
||||
content: str
|
||||
access_control: Optional[dict] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
tags: Optional[list[str]] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
version_id: Optional[str] = None # Active version
|
||||
commit_message: Optional[str] = None # For history tracking
|
||||
is_production: Optional[bool] = True # Whether to set new version as production
|
||||
|
||||
|
||||
class PromptsTable:
|
||||
def _get_access_grants(
|
||||
self, prompt_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
|
||||
|
||||
def _to_prompt_model(
|
||||
self, prompt: Prompt, db: Optional[Session] = None
|
||||
) -> PromptModel:
|
||||
prompt_data = PromptModel.model_validate(prompt).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
|
||||
return PromptModel.model_validate(prompt_data)
|
||||
|
||||
def insert_new_prompt(
|
||||
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
|
||||
) -> Optional[PromptModel]:
|
||||
now = int(time.time())
|
||||
prompt_id = str(uuid.uuid4())
|
||||
|
||||
prompt = PromptModel(
|
||||
**{
|
||||
"user_id": user_id,
|
||||
**form_data.model_dump(),
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
id=prompt_id,
|
||||
user_id=user_id,
|
||||
command=form_data.command,
|
||||
name=form_data.name,
|
||||
content=form_data.content,
|
||||
data=form_data.data or {},
|
||||
meta=form_data.meta or {},
|
||||
tags=form_data.tags or [],
|
||||
access_grants=[],
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
result = Prompt(**prompt.model_dump())
|
||||
result = Prompt(**prompt.model_dump(exclude={"access_grants"}))
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"prompt", prompt_id, form_data.access_grants, db=db
|
||||
)
|
||||
|
||||
if result:
|
||||
return PromptModel.model_validate(result)
|
||||
current_access_grants = self._get_access_grants(prompt_id, db=db)
|
||||
snapshot = {
|
||||
"name": form_data.name,
|
||||
"content": form_data.content,
|
||||
"command": form_data.command,
|
||||
"data": form_data.data or {},
|
||||
"meta": form_data.meta or {},
|
||||
"tags": form_data.tags or [],
|
||||
"access_grants": [
|
||||
grant.model_dump() for grant in current_access_grants
|
||||
],
|
||||
}
|
||||
|
||||
history_entry = PromptHistories.create_history_entry(
|
||||
prompt_id=prompt_id,
|
||||
snapshot=snapshot,
|
||||
user_id=user_id,
|
||||
parent_id=None, # Initial commit has no parent
|
||||
commit_message=form_data.commit_message or "Initial version",
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Set the initial version as the production version
|
||||
if history_entry:
|
||||
result.version_id = history_entry.id
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
|
||||
return self._to_prompt_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_prompt_by_id(
|
||||
self, prompt_id: str, db: Optional[Session] = None
|
||||
) -> Optional[PromptModel]:
|
||||
"""Get prompt by UUID."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if prompt:
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_prompt_by_command(
|
||||
self, command: str, db: Optional[Session] = None
|
||||
) -> Optional[PromptModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||
return PromptModel.model_validate(prompt)
|
||||
if prompt:
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]:
|
||||
with get_db_context(db) as db:
|
||||
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
|
||||
all_prompts = (
|
||||
db.query(Prompt)
|
||||
.filter(Prompt.is_active == True)
|
||||
.order_by(Prompt.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
user_ids = list(set(prompt.user_id for prompt in all_prompts))
|
||||
|
||||
@@ -124,7 +216,7 @@ class PromptsTable:
|
||||
prompts.append(
|
||||
PromptUserResponse.model_validate(
|
||||
{
|
||||
**PromptModel.model_validate(prompt).model_dump(),
|
||||
**self._to_prompt_model(prompt, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -144,35 +236,400 @@ class PromptsTable:
|
||||
prompt
|
||||
for prompt in prompts
|
||||
if prompt.user_id == user_id
|
||||
or has_access(user_id, permission, prompt.access_control, user_group_ids)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
def search_prompts(
|
||||
self,
|
||||
user_id: str,
|
||||
filter: dict = {},
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[Session] = None,
|
||||
) -> PromptListResponse:
|
||||
with get_db_context(db) as db:
|
||||
from open_webui.models.users import User, UserModel
|
||||
|
||||
# Join with User table for user filtering and sorting
|
||||
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
|
||||
query = query.filter(Prompt.is_active == True)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Prompt.name.ilike(f"%{query_key}%"),
|
||||
Prompt.command.ilike(f"%{query_key}%"),
|
||||
Prompt.content.ilike(f"%{query_key}%"),
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
view_option = filter.get("view_option")
|
||||
if view_option == "created":
|
||||
query = query.filter(Prompt.user_id == user_id)
|
||||
elif view_option == "shared":
|
||||
query = query.filter(Prompt.user_id != user_id)
|
||||
|
||||
# Apply access grant filtering
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Prompt,
|
||||
filter=filter,
|
||||
resource_type="prompt",
|
||||
permission="read",
|
||||
)
|
||||
|
||||
tag = filter.get("tag")
|
||||
if tag:
|
||||
# Search for tag in JSON array field
|
||||
like_pattern = f'%"{tag.lower()}"%'
|
||||
tags_text = func.lower(cast(Prompt.tags, String))
|
||||
query = query.filter(tags_text.like(like_pattern))
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by == "name":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Prompt.name.asc())
|
||||
else:
|
||||
query = query.order_by(Prompt.name.desc())
|
||||
elif order_by == "created_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Prompt.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(Prompt.created_at.desc())
|
||||
elif order_by == "updated_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Prompt.updated_at.asc())
|
||||
else:
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
else:
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
else:
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
items = query.all()
|
||||
|
||||
prompts = []
|
||||
for prompt, user in items:
|
||||
prompts.append(
|
||||
PromptUserResponse(
|
||||
**self._to_prompt_model(prompt, db=db).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return PromptListResponse(items=prompts, total=total)
|
||||
|
||||
def update_prompt_by_command(
|
||||
self, command: str, form_data: PromptForm, db: Optional[Session] = None
|
||||
self,
|
||||
command: str,
|
||||
form_data: PromptForm,
|
||||
user_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[PromptModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||
prompt.title = form_data.title
|
||||
if not prompt:
|
||||
return None
|
||||
|
||||
latest_history = PromptHistories.get_latest_history_entry(
|
||||
prompt.id, db=db
|
||||
)
|
||||
parent_id = latest_history.id if latest_history else None
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
# Check if content changed to decide on history creation
|
||||
content_changed = (
|
||||
prompt.name != form_data.name
|
||||
or prompt.content != form_data.content
|
||||
or form_data.access_grants is not None
|
||||
)
|
||||
|
||||
# Update prompt fields
|
||||
prompt.name = form_data.name
|
||||
prompt.content = form_data.content
|
||||
prompt.access_control = form_data.access_control
|
||||
prompt.timestamp = int(time.time())
|
||||
prompt.data = form_data.data or prompt.data
|
||||
prompt.meta = form_data.meta or prompt.meta
|
||||
prompt.updated_at = int(time.time())
|
||||
if form_data.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"prompt", prompt.id, form_data.access_grants, db=db
|
||||
)
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
db.commit()
|
||||
return PromptModel.model_validate(prompt)
|
||||
|
||||
# Create history entry only if content changed
|
||||
if content_changed:
|
||||
snapshot = {
|
||||
"name": form_data.name,
|
||||
"content": form_data.content,
|
||||
"command": command,
|
||||
"data": form_data.data or {},
|
||||
"meta": form_data.meta or {},
|
||||
"access_grants": [
|
||||
grant.model_dump() for grant in current_access_grants
|
||||
],
|
||||
}
|
||||
|
||||
history_entry = PromptHistories.create_history_entry(
|
||||
prompt_id=prompt.id,
|
||||
snapshot=snapshot,
|
||||
user_id=user_id,
|
||||
parent_id=parent_id,
|
||||
commit_message=form_data.commit_message,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Set as production if flag is True (default)
|
||||
if form_data.is_production and history_entry:
|
||||
prompt.version_id = history_entry.id
|
||||
db.commit()
|
||||
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_prompt_by_id(
|
||||
self,
|
||||
prompt_id: str,
|
||||
form_data: PromptForm,
|
||||
user_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[PromptModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if not prompt:
|
||||
return None
|
||||
|
||||
latest_history = PromptHistories.get_latest_history_entry(
|
||||
prompt.id, db=db
|
||||
)
|
||||
parent_id = latest_history.id if latest_history else None
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
# Check if content changed to decide on history creation
|
||||
content_changed = (
|
||||
prompt.name != form_data.name
|
||||
or prompt.command != form_data.command
|
||||
or prompt.content != form_data.content
|
||||
or form_data.access_grants is not None
|
||||
or (form_data.tags is not None and prompt.tags != form_data.tags)
|
||||
)
|
||||
|
||||
# Update prompt fields
|
||||
prompt.name = form_data.name
|
||||
prompt.command = form_data.command
|
||||
prompt.content = form_data.content
|
||||
prompt.data = form_data.data or prompt.data
|
||||
prompt.meta = form_data.meta or prompt.meta
|
||||
|
||||
if form_data.tags is not None:
|
||||
prompt.tags = form_data.tags
|
||||
|
||||
if form_data.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"prompt", prompt.id, form_data.access_grants, db=db
|
||||
)
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
prompt.updated_at = int(time.time())
|
||||
|
||||
db.commit()
|
||||
|
||||
# Create history entry only if content changed
|
||||
if content_changed:
|
||||
snapshot = {
|
||||
"name": form_data.name,
|
||||
"content": form_data.content,
|
||||
"command": prompt.command,
|
||||
"data": form_data.data or {},
|
||||
"meta": form_data.meta or {},
|
||||
"tags": prompt.tags or [],
|
||||
"access_grants": [
|
||||
grant.model_dump() for grant in current_access_grants
|
||||
],
|
||||
}
|
||||
|
||||
history_entry = PromptHistories.create_history_entry(
|
||||
prompt_id=prompt.id,
|
||||
snapshot=snapshot,
|
||||
user_id=user_id,
|
||||
parent_id=parent_id,
|
||||
commit_message=form_data.commit_message,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Set as production if flag is True (default)
|
||||
if form_data.is_production and history_entry:
|
||||
prompt.version_id = history_entry.id
|
||||
db.commit()
|
||||
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_prompt_metadata(
|
||||
self,
|
||||
prompt_id: str,
|
||||
name: str,
|
||||
command: str,
|
||||
tags: Optional[list[str]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[PromptModel]:
|
||||
"""Update only name, command, and tags (no history created)."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if not prompt:
|
||||
return None
|
||||
|
||||
prompt.name = name
|
||||
prompt.command = command
|
||||
|
||||
if tags is not None:
|
||||
prompt.tags = tags
|
||||
|
||||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_prompt_version(
|
||||
self,
|
||||
prompt_id: str,
|
||||
version_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[PromptModel]:
|
||||
"""Set the active version of a prompt and restore content from that version's snapshot."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if not prompt:
|
||||
return None
|
||||
|
||||
history_entry = PromptHistories.get_history_entry_by_id(
|
||||
version_id, db=db
|
||||
)
|
||||
|
||||
if not history_entry:
|
||||
return None
|
||||
|
||||
# Restore prompt content from the snapshot
|
||||
snapshot = history_entry.snapshot
|
||||
if snapshot:
|
||||
prompt.name = snapshot.get("name", prompt.name)
|
||||
prompt.content = snapshot.get("content", prompt.content)
|
||||
prompt.data = snapshot.get("data", prompt.data)
|
||||
prompt.meta = snapshot.get("meta", prompt.meta)
|
||||
prompt.tags = snapshot.get("tags", prompt.tags)
|
||||
# Note: command and access_grants are not restored from snapshot
|
||||
|
||||
prompt.version_id = version_id
|
||||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def delete_prompt_by_command(
|
||||
self, command: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
"""Soft delete a prompt by setting is_active to False."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(Prompt).filter_by(command=command).delete()
|
||||
db.commit()
|
||||
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
return True
|
||||
prompt.is_active = False
|
||||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
|
||||
"""Soft delete a prompt by setting is_active to False."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
prompt.is_active = False
|
||||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def hard_delete_prompt_by_command(
|
||||
self, command: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
"""Permanently delete a prompt and its history."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
# Delete prompt
|
||||
db.query(Prompt).filter_by(command=command).delete()
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_tags(self, db: Optional[Session] = None) -> list[str]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompts = db.query(Prompt).filter_by(is_active=True).all()
|
||||
tags = set()
|
||||
for prompt in prompts:
|
||||
if prompt.tags:
|
||||
for tag in prompt.tags:
|
||||
if tag:
|
||||
tags.add(tag)
|
||||
return sorted(list(tags))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
Prompts = PromptsTable()
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db, get_db_context
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, or_
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
# Skills DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class Skill(Base):
|
||||
__tablename__ = "skill"
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
user_id = Column(String)
|
||||
name = Column(Text, unique=True)
|
||||
description = Column(Text, nullable=True)
|
||||
content = Column(Text)
|
||||
meta = Column(JSON)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
|
||||
class SkillMeta(BaseModel):
|
||||
tags: Optional[list[str]] = []
|
||||
|
||||
|
||||
class SkillModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
content: str
|
||||
meta: SkillMeta
|
||||
is_active: bool = True
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class SkillUserModel(SkillModel):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class SkillResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
meta: SkillMeta
|
||||
is_active: bool = True
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
|
||||
class SkillUserResponse(SkillResponse):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class SkillAccessResponse(SkillUserResponse):
|
||||
write_access: Optional[bool] = False
|
||||
|
||||
|
||||
class SkillForm(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
content: str
|
||||
meta: SkillMeta = SkillMeta()
|
||||
is_active: bool = True
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SkillListResponse(BaseModel):
|
||||
items: list[SkillUserResponse] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class SkillAccessListResponse(BaseModel):
|
||||
items: list[SkillAccessResponse] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class SkillsTable:
|
||||
def _get_access_grants(
|
||||
self, skill_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("skill", skill_id, db=db)
|
||||
|
||||
def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel:
|
||||
skill_data = SkillModel.model_validate(skill).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db)
|
||||
return SkillModel.model_validate(skill_data)
|
||||
|
||||
def insert_new_skill(
|
||||
self,
|
||||
user_id: str,
|
||||
form_data: SkillForm,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[SkillModel]:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
result = Skill(
|
||||
**{
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"skill", result.id, form_data.access_grants, db=db
|
||||
)
|
||||
if result:
|
||||
return self._to_skill_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f"Error creating a new skill: {e}")
|
||||
return None
|
||||
|
||||
def get_skill_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[SkillModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
skill = db.get(Skill, id)
|
||||
return self._to_skill_model(skill, db=db) if skill else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_skill_by_name(
|
||||
self, name: str, db: Optional[Session] = None
|
||||
) -> Optional[SkillModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
skill = db.query(Skill).filter_by(name=name).first()
|
||||
return self._to_skill_model(skill, db=db) if skill else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]:
|
||||
with get_db_context(db) as db:
|
||||
all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all()
|
||||
|
||||
user_ids = list(set(skill.user_id for skill in all_skills))
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
skills = []
|
||||
for skill in all_skills:
|
||||
user = users_dict.get(skill.user_id)
|
||||
skills.append(
|
||||
SkillUserModel.model_validate(
|
||||
{
|
||||
**self._to_skill_model(skill, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
)
|
||||
return skills
|
||||
|
||||
def get_skills_by_user_id(
|
||||
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||
) -> list[SkillUserModel]:
|
||||
skills = self.get_skills(db=db)
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||
}
|
||||
|
||||
return [
|
||||
skill
|
||||
for skill in skills
|
||||
if skill.user_id == user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
def search_skills(
|
||||
self,
|
||||
user_id: str,
|
||||
filter: dict = {},
|
||||
skip: int = 0,
|
||||
limit: int = 30,
|
||||
db: Optional[Session] = None,
|
||||
) -> SkillListResponse:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
from open_webui.models.users import User, UserModel
|
||||
|
||||
# Join with User table for user filtering
|
||||
query = db.query(Skill, User).outerjoin(User, User.id == Skill.user_id)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Skill.name.ilike(f"%{query_key}%"),
|
||||
Skill.description.ilike(f"%{query_key}%"),
|
||||
Skill.id.ilike(f"%{query_key}%"),
|
||||
User.name.ilike(f"%{query_key}%"),
|
||||
User.email.ilike(f"%{query_key}%"),
|
||||
)
|
||||
)
|
||||
|
||||
view_option = filter.get("view_option")
|
||||
if view_option == "created":
|
||||
query = query.filter(Skill.user_id == user_id)
|
||||
elif view_option == "shared":
|
||||
query = query.filter(Skill.user_id != user_id)
|
||||
|
||||
# Apply access grant filtering
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Skill,
|
||||
filter=filter,
|
||||
resource_type="skill",
|
||||
permission="read",
|
||||
)
|
||||
|
||||
query = query.order_by(Skill.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
items = query.all()
|
||||
|
||||
skills = []
|
||||
for skill, user in items:
|
||||
skills.append(
|
||||
SkillUserResponse(
|
||||
**self._to_skill_model(skill, db=db).model_dump(),
|
||||
user=(
|
||||
UserResponse(
|
||||
**UserModel.model_validate(user).model_dump()
|
||||
)
|
||||
if user
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return SkillListResponse(items=skills, total=total)
|
||||
except Exception as e:
|
||||
log.exception(f"Error searching skills: {e}")
|
||||
return SkillListResponse(items=[], total=0)
|
||||
|
||||
def update_skill_by_id(
|
||||
self, id: str, updated: dict, db: Optional[Session] = None
|
||||
) -> Optional[SkillModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
access_grants = updated.pop("access_grants", None)
|
||||
db.query(Skill).filter_by(id=id).update(
|
||||
{**updated, "updated_at": int(time.time())}
|
||||
)
|
||||
db.commit()
|
||||
if access_grants is not None:
|
||||
AccessGrants.set_access_grants("skill", id, access_grants, db=db)
|
||||
|
||||
skill = db.query(Skill).get(id)
|
||||
db.refresh(skill)
|
||||
return self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def toggle_skill_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[SkillModel]:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
skill = db.query(Skill).filter_by(id=id).first()
|
||||
if not skill:
|
||||
return None
|
||||
|
||||
skill.is_active = not skill.is_active
|
||||
skill.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(skill)
|
||||
|
||||
return self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("skill", id, db=db)
|
||||
db.query(Skill).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
Skills = SkillsTable()
|
||||
@@ -115,5 +115,45 @@ class TagTable:
|
||||
log.error(f"delete_tag: {e}")
|
||||
return False
|
||||
|
||||
def delete_tags_by_ids_and_user_id(
|
||||
self, ids: list[str], user_id: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
"""Delete all tags whose id is in *ids* for the given user, in one query."""
|
||||
if not ids:
|
||||
return True
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"delete_tags_by_ids: {e}")
|
||||
return False
|
||||
|
||||
def ensure_tags_exist(
|
||||
self, names: list[str], user_id: str, db: Optional[Session] = None
|
||||
) -> None:
|
||||
"""Create tag rows for any *names* that don't already exist for *user_id*."""
|
||||
if not names:
|
||||
return
|
||||
ids = [n.replace(" ", "_").lower() for n in names]
|
||||
with get_db_context(db) as db:
|
||||
existing = {
|
||||
t.id
|
||||
for t in db.query(Tag.id)
|
||||
.filter(Tag.id.in_(ids), Tag.user_id == user_id)
|
||||
.all()
|
||||
}
|
||||
new_tags = [
|
||||
Tag(id=tag_id, name=name, user_id=user_id)
|
||||
for tag_id, name in zip(ids, names)
|
||||
if tag_id not in existing
|
||||
]
|
||||
if new_tags:
|
||||
db.add_all(new_tags)
|
||||
db.commit()
|
||||
|
||||
|
||||
Tags = TagTable()
|
||||
|
||||
@@ -6,12 +6,10 @@ from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Column, String, Text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,23 +29,6 @@ class Tool(Base):
|
||||
meta = Column(JSONField)
|
||||
valves = Column(JSONField)
|
||||
|
||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||
# Defines access control rules for this entry.
|
||||
# - `None`: Public access, available to all users with the "user" role.
|
||||
# - `{}`: Private access, restricted exclusively to the owner.
|
||||
# - Custom permissions: Specific access control for reading and writing;
|
||||
# Can specify group or user-level restrictions:
|
||||
# {
|
||||
# "read": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# },
|
||||
# "write": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# }
|
||||
# }
|
||||
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
@@ -64,7 +45,7 @@ class ToolModel(BaseModel):
|
||||
content: str
|
||||
specs: list[dict]
|
||||
meta: ToolMeta
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
@@ -86,7 +67,7 @@ class ToolResponse(BaseModel):
|
||||
user_id: str
|
||||
name: str
|
||||
meta: ToolMeta
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
@@ -106,7 +87,7 @@ class ToolForm(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
meta: ToolMeta
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class ToolValves(BaseModel):
|
||||
@@ -114,6 +95,16 @@ class ToolValves(BaseModel):
|
||||
|
||||
|
||||
class ToolsTable:
|
||||
def _get_access_grants(
|
||||
self, tool_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
|
||||
|
||||
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
|
||||
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
|
||||
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
|
||||
return ToolModel.model_validate(tool_data)
|
||||
|
||||
def insert_new_tool(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -122,23 +113,24 @@ class ToolsTable:
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[ToolModel]:
|
||||
with get_db_context(db) as db:
|
||||
tool = ToolModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
"specs": specs,
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
result = Tool(**tool.model_dump())
|
||||
result = Tool(
|
||||
**{
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"specs": specs,
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"tool", result.id, form_data.access_grants, db=db
|
||||
)
|
||||
if result:
|
||||
return ToolModel.model_validate(result)
|
||||
return self._to_tool_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -151,7 +143,7 @@ class ToolsTable:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
tool = db.get(Tool, id)
|
||||
return ToolModel.model_validate(tool)
|
||||
return self._to_tool_model(tool, db=db) if tool else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -170,7 +162,7 @@ class ToolsTable:
|
||||
tools.append(
|
||||
ToolUserModel.model_validate(
|
||||
{
|
||||
**ToolModel.model_validate(tool).model_dump(),
|
||||
**self._to_tool_model(tool, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -189,7 +181,14 @@ class ToolsTable:
|
||||
tool
|
||||
for tool in tools
|
||||
if tool.user_id == user_id
|
||||
or has_access(user_id, permission, tool.access_control, user_group_ids)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="tool",
|
||||
resource_id=tool.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
def get_tool_valves_by_id(
|
||||
@@ -266,20 +265,24 @@ class ToolsTable:
|
||||
) -> Optional[ToolModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
access_grants = updated.pop("access_grants", None)
|
||||
db.query(Tool).filter_by(id=id).update(
|
||||
{**updated, "updated_at": int(time.time())}
|
||||
)
|
||||
db.commit()
|
||||
if access_grants is not None:
|
||||
AccessGrants.set_access_grants("tool", id, access_grants, db=db)
|
||||
|
||||
tool = db.query(Tool).get(id)
|
||||
db.refresh(tool)
|
||||
return ToolModel.model_validate(tool)
|
||||
return self._to_tool_model(tool, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("tool", id, db=db)
|
||||
db.query(Tool).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, defer
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ from open_webui.models.groups import Groups, GroupMember
|
||||
from open_webui.models.channels import ChannelMember
|
||||
|
||||
from open_webui.utils.misc import throttle
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
JSON,
|
||||
@@ -27,7 +28,7 @@ from sqlalchemy import (
|
||||
select,
|
||||
cast,
|
||||
)
|
||||
from sqlalchemy import or_, case
|
||||
from sqlalchemy import or_, case, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
import datetime
|
||||
@@ -70,6 +71,7 @@ class User(Base):
|
||||
settings = Column(JSON, nullable=True)
|
||||
|
||||
oauth = Column(JSON, nullable=True)
|
||||
scim = Column(JSON, nullable=True)
|
||||
|
||||
last_active_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
@@ -85,7 +87,7 @@ class UserModel(BaseModel):
|
||||
|
||||
name: str
|
||||
|
||||
profile_image_url: str
|
||||
profile_image_url: Optional[str] = None
|
||||
profile_banner_image_url: Optional[str] = None
|
||||
|
||||
bio: Optional[str] = None
|
||||
@@ -102,6 +104,7 @@ class UserModel(BaseModel):
|
||||
settings: Optional[UserSettings] = None
|
||||
|
||||
oauth: Optional[dict] = None
|
||||
scim: Optional[dict] = None
|
||||
|
||||
last_active_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
@@ -109,6 +112,12 @@ class UserModel(BaseModel):
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def set_profile_image_url(self):
|
||||
if not self.profile_image_url:
|
||||
self.profile_image_url = f"/api/v1/users/{self.id}/profile/image"
|
||||
return self
|
||||
|
||||
|
||||
class UserStatusModel(UserModel):
|
||||
is_active: bool = False
|
||||
@@ -154,6 +163,11 @@ class UpdateProfileForm(BaseModel):
|
||||
gender: Optional[str] = None
|
||||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
@field_validator("profile_image_url")
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: str) -> str:
|
||||
return validate_profile_image_url(v)
|
||||
|
||||
|
||||
class UserGroupIdsModel(UserModel):
|
||||
group_ids: list[str] = []
|
||||
@@ -184,6 +198,9 @@ class UserInfoResponse(UserStatus):
|
||||
name: str
|
||||
email: str
|
||||
role: str
|
||||
bio: Optional[str] = None
|
||||
groups: Optional[list] = []
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class UserIdNameResponse(BaseModel):
|
||||
@@ -234,6 +251,11 @@ class UserUpdateForm(BaseModel):
|
||||
profile_image_url: str
|
||||
password: Optional[str] = None
|
||||
|
||||
@field_validator("profile_image_url")
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: str) -> str:
|
||||
return validate_profile_image_url(v)
|
||||
|
||||
|
||||
class UsersTable:
|
||||
def insert_new_user(
|
||||
@@ -243,6 +265,7 @@ class UsersTable:
|
||||
email: str,
|
||||
profile_image_url: str = "/user.png",
|
||||
role: str = "pending",
|
||||
username: Optional[str] = None,
|
||||
oauth: Optional[dict] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[UserModel]:
|
||||
@@ -257,6 +280,7 @@ class UsersTable:
|
||||
"last_active_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
"username": username,
|
||||
"oauth": oauth,
|
||||
}
|
||||
)
|
||||
@@ -299,8 +323,12 @@ class UsersTable:
|
||||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
user = db.query(User).filter_by(email=email).first()
|
||||
return UserModel.model_validate(user)
|
||||
user = (
|
||||
db.query(User)
|
||||
.filter(func.lower(User.email) == email.lower())
|
||||
.first()
|
||||
)
|
||||
return UserModel.model_validate(user) if user else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -325,6 +353,29 @@ class UsersTable:
|
||||
# You may want to log the exception here
|
||||
return None
|
||||
|
||||
def get_user_by_scim_external_id(
|
||||
self, provider: str, external_id: str, db: Optional[Session] = None
|
||||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db: # type: Session
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
query = db.query(User)
|
||||
if dialect_name == "sqlite":
|
||||
query = query.filter(
|
||||
User.scim.contains({provider: {"external_id": external_id}})
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
query = query.filter(
|
||||
User.scim[provider].cast(JSONB)["external_id"].astext
|
||||
== external_id
|
||||
)
|
||||
|
||||
user = query.first()
|
||||
return UserModel.model_validate(user) if user else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_users(
|
||||
self,
|
||||
filter: Optional[dict] = None,
|
||||
@@ -334,7 +385,7 @@ class UsersTable:
|
||||
) -> dict:
|
||||
with get_db_context(db) as db:
|
||||
# Join GroupMember so we can order by group_id when requested
|
||||
query = db.query(User)
|
||||
query = db.query(User).options(defer(User.profile_image_url))
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
@@ -469,6 +520,7 @@ class UsersTable:
|
||||
with get_db_context(db) as db:
|
||||
users = (
|
||||
db.query(User)
|
||||
.options(defer(User.profile_image_url))
|
||||
.join(GroupMember, User.id == GroupMember.user_id)
|
||||
.filter(GroupMember.group_id == group_id)
|
||||
.all()
|
||||
@@ -479,7 +531,12 @@ class UsersTable:
|
||||
self, user_ids: list[str], db: Optional[Session] = None
|
||||
) -> list[UserStatusModel]:
|
||||
with get_db_context(db) as db:
|
||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||
users = (
|
||||
db.query(User)
|
||||
.options(defer(User.profile_image_url))
|
||||
.filter(User.id.in_(user_ids))
|
||||
.all()
|
||||
)
|
||||
return [UserModel.model_validate(user) for user in users]
|
||||
|
||||
def get_num_users(self, db: Optional[Session] = None) -> Optional[int]:
|
||||
@@ -530,9 +587,12 @@ class UsersTable:
|
||||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update({"role": role})
|
||||
db.commit()
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
user.role = role
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -542,12 +602,13 @@ class UsersTable:
|
||||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update(
|
||||
{**form_data.model_dump(exclude_none=True)}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
for key, value in form_data.model_dump(exclude_none=True).items():
|
||||
setattr(user, key, value)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -557,12 +618,12 @@ class UsersTable:
|
||||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update(
|
||||
{"profile_image_url": profile_image_url}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
user.profile_image_url = profile_image_url
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -573,12 +634,12 @@ class UsersTable:
|
||||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update(
|
||||
{"last_active_at": int(time.time())}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
user.last_active_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -615,17 +676,51 @@ class UsersTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_user_scim_by_id(
|
||||
self,
|
||||
id: str,
|
||||
provider: str,
|
||||
external_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[UserModel]:
|
||||
"""
|
||||
Update or insert a SCIM provider/external_id pair into the user's scim JSON field.
|
||||
Example resulting structure:
|
||||
{
|
||||
"microsoft": { "external_id": "abc" },
|
||||
"okta": { "external_id": "def" }
|
||||
}
|
||||
"""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
scim = user.scim or {}
|
||||
scim[provider] = {"external_id": external_id}
|
||||
|
||||
db.query(User).filter_by(id=id).update({"scim": scim})
|
||||
db.commit()
|
||||
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_user_by_id(
|
||||
self, id: str, updated: dict, db: Optional[Session] = None
|
||||
) -> Optional[UserModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
db.query(User).filter_by(id=id).update(updated)
|
||||
db.commit()
|
||||
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
for key, value in updated.items():
|
||||
setattr(user, key, value)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
# return UserModel(**user.dict())
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return None
|
||||
@@ -740,6 +835,14 @@ class UsersTable:
|
||||
)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def is_active(user: UserModel) -> bool:
|
||||
"""Compute active status from an already-loaded UserModel (no DB hit)."""
|
||||
if user.last_active_at:
|
||||
three_minutes_ago = int(time.time()) - 180
|
||||
return user.last_active_at >= three_minutes_ago
|
||||
return False
|
||||
|
||||
def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
user = db.query(User).filter_by(id=user_id).first()
|
||||
|
||||
@@ -143,7 +143,7 @@ class DoclingLoader:
|
||||
with open(self.file_path, "rb") as f:
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers["X-Api-Key"] = f"Bearer {self.api_key}"
|
||||
headers["X-Api-Key"] = f"{self.api_key}"
|
||||
|
||||
r = requests.post(
|
||||
f"{self.url}/v1/convert/file",
|
||||
@@ -361,7 +361,9 @@ class Loader:
|
||||
else:
|
||||
if file_ext == "pdf":
|
||||
loader = PyPDFLoader(
|
||||
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
|
||||
file_path,
|
||||
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
|
||||
mode=self.kwargs.get("PDF_LOADER_MODE", "page"),
|
||||
)
|
||||
elif file_ext == "csv":
|
||||
loader = CSVLoader(file_path, autodetect_encoding=True)
|
||||
|
||||
@@ -8,7 +8,6 @@ from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, REQUESTS_VERIFY
|
||||
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ from open_webui.models.knowledge import Knowledges
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
from open_webui.retrieval.vector.main import GetResult
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.misc import get_message_list
|
||||
|
||||
@@ -601,7 +601,10 @@ async def agenerate_openai_batch_embeddings(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(
|
||||
f"{url}/embeddings", headers=headers, json=form_data
|
||||
f"{url}/embeddings",
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
@@ -691,7 +694,12 @@ async def agenerate_azure_openai_batch_embeddings(
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(full_url, headers=headers, json=form_data) as r:
|
||||
async with session.post(
|
||||
full_url,
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
if "data" in data:
|
||||
@@ -999,7 +1007,12 @@ async def get_sources_from_items(
|
||||
if note and (
|
||||
user.role == "admin"
|
||||
or note.user_id == user.id
|
||||
or has_access(user.id, "read", note.access_control)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
# User has access to the note
|
||||
query_result = {
|
||||
@@ -1091,7 +1104,12 @@ async def get_sources_from_items(
|
||||
if knowledge_base and (
|
||||
user.role == "admin"
|
||||
or knowledge_base.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge_base.access_control)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
if (
|
||||
item.get("context") == "full"
|
||||
@@ -1100,7 +1118,12 @@ async def get_sources_from_items(
|
||||
if knowledge_base and (
|
||||
user.role == "admin"
|
||||
or knowledge_base.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge_base.access_control)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
files = Knowledges.get_files_by_id(knowledge_base.id)
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ class OpenSearchClient(VectorDBBase):
|
||||
for item in batch
|
||||
]
|
||||
bulk(self.client, actions)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
self.client.indices.refresh(index=self._get_index_name(collection_name))
|
||||
|
||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||
self._create_index_if_not_exists(
|
||||
@@ -234,7 +234,7 @@ class OpenSearchClient(VectorDBBase):
|
||||
for item in batch
|
||||
]
|
||||
bulk(self.client, actions)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
self.client.indices.refresh(index=self._get_index_name(collection_name))
|
||||
|
||||
def delete(
|
||||
self,
|
||||
@@ -263,7 +263,7 @@ class OpenSearchClient(VectorDBBase):
|
||||
self.client.delete_by_query(
|
||||
index=self._get_index_name(collection_name), body=query_body
|
||||
)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
self.client.indices.refresh(index=self._get_index_name(collection_name))
|
||||
|
||||
def reset(self):
|
||||
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
|
||||
|
||||
@@ -256,8 +256,7 @@ class Oracle23aiClient(VectorDBBase):
|
||||
with connection.cursor() as cursor:
|
||||
try:
|
||||
log.info("Creating Table document_chunk")
|
||||
cursor.execute(
|
||||
"""
|
||||
cursor.execute("""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE TABLE IF NOT EXISTS document_chunk (
|
||||
@@ -274,12 +273,10 @@ class Oracle23aiClient(VectorDBBase):
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
log.info("Creating Index document_chunk_collection_name_idx")
|
||||
cursor.execute(
|
||||
"""
|
||||
cursor.execute("""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx
|
||||
@@ -291,12 +288,10 @@ class Oracle23aiClient(VectorDBBase):
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx")
|
||||
cursor.execute(
|
||||
"""
|
||||
cursor.execute("""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE '
|
||||
CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx
|
||||
@@ -312,8 +307,7 @@ class Oracle23aiClient(VectorDBBase):
|
||||
RAISE;
|
||||
END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
connection.commit()
|
||||
log.info("Database initialization completed successfully.")
|
||||
|
||||
@@ -51,7 +51,6 @@ from open_webui.config import (
|
||||
PGVECTOR_USE_HALFVEC,
|
||||
)
|
||||
|
||||
|
||||
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
|
||||
USE_HALFVEC = PGVECTOR_USE_HALFVEC
|
||||
|
||||
@@ -121,34 +120,26 @@ class PgvectorClient(VectorDBBase):
|
||||
# Ensure the pgvector extension is available
|
||||
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||
if PGVECTOR_CREATE_EXTENSION:
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
self.session.execute(text("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
"""))
|
||||
|
||||
if PGVECTOR_PGCRYPTO:
|
||||
# Ensure the pgcrypto extension is available for encryption
|
||||
# Use a conditional check to avoid permission issues on Azure PostgreSQL
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
self.session.execute(text("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
)
|
||||
"""))
|
||||
|
||||
if not PGVECTOR_PGCRYPTO_KEY:
|
||||
raise ValueError(
|
||||
@@ -216,15 +207,13 @@ class PgvectorClient(VectorDBBase):
|
||||
def _ensure_vector_index(self, index_method: str, index_options: str) -> None:
|
||||
index_name = "idx_document_chunk_vector"
|
||||
existing_index_def = self.session.execute(
|
||||
text(
|
||||
"""
|
||||
text("""
|
||||
SELECT indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename = 'document_chunk'
|
||||
AND indexname = :index_name
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{"index_name": index_name},
|
||||
).scalar()
|
||||
|
||||
@@ -310,8 +299,7 @@ class PgvectorClient(VectorDBBase):
|
||||
# Ensure metadata is converted to its JSON text representation
|
||||
json_metadata = json.dumps(item["metadata"])
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
text("""
|
||||
INSERT INTO document_chunk
|
||||
(id, vector, collection_name, text, vmetadata)
|
||||
VALUES (
|
||||
@@ -320,8 +308,7 @@ class PgvectorClient(VectorDBBase):
|
||||
pgp_sym_encrypt(:metadata_text, :key)
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": item["id"],
|
||||
"vector": vector,
|
||||
@@ -363,8 +350,7 @@ class PgvectorClient(VectorDBBase):
|
||||
vector = self.adjust_vector_length(item["vector"])
|
||||
json_metadata = json.dumps(item["metadata"])
|
||||
self.session.execute(
|
||||
text(
|
||||
"""
|
||||
text("""
|
||||
INSERT INTO document_chunk
|
||||
(id, vector, collection_name, text, vmetadata)
|
||||
VALUES (
|
||||
@@ -377,8 +363,7 @@ class PgvectorClient(VectorDBBase):
|
||||
collection_name = EXCLUDED.collection_name,
|
||||
text = EXCLUDED.text,
|
||||
vmetadata = EXCLUDED.vmetadata
|
||||
"""
|
||||
),
|
||||
"""),
|
||||
{
|
||||
"id": item["id"],
|
||||
"vector": vector,
|
||||
|
||||
@@ -33,7 +33,6 @@ from open_webui.config import (
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
|
||||
|
||||
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
|
||||
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
|
||||
|
||||
|
||||
@@ -12,9 +12,13 @@ from open_webui.retrieval.vector.main import (
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.config import (
|
||||
WEAVIATE_HTTP_HOST,
|
||||
WEAVIATE_GRPC_HOST,
|
||||
WEAVIATE_HTTP_PORT,
|
||||
WEAVIATE_GRPC_PORT,
|
||||
WEAVIATE_API_KEY,
|
||||
WEAVIATE_HTTP_SECURE,
|
||||
WEAVIATE_GRPC_SECURE,
|
||||
WEAVIATE_SKIP_INIT_CHECKS,
|
||||
)
|
||||
|
||||
|
||||
@@ -52,9 +56,13 @@ class WeaviateClient(VectorDBBase):
|
||||
try:
|
||||
# Build connection parameters
|
||||
connection_params = {
|
||||
"host": WEAVIATE_HTTP_HOST,
|
||||
"port": WEAVIATE_HTTP_PORT,
|
||||
"http_host": WEAVIATE_HTTP_HOST,
|
||||
"http_port": WEAVIATE_HTTP_PORT,
|
||||
"http_secure": WEAVIATE_HTTP_SECURE,
|
||||
"grpc_host": WEAVIATE_GRPC_HOST,
|
||||
"grpc_port": WEAVIATE_GRPC_PORT,
|
||||
"grpc_secure": WEAVIATE_GRPC_SECURE,
|
||||
"skip_init_checks": WEAVIATE_SKIP_INIT_CHECKS,
|
||||
}
|
||||
|
||||
# Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty
|
||||
@@ -63,7 +71,7 @@ class WeaviateClient(VectorDBBase):
|
||||
weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY)
|
||||
)
|
||||
|
||||
self.client = weaviate.connect_to_local(**connection_params)
|
||||
self.client = weaviate.connect_to_custom(**connection_params)
|
||||
self.client.connect()
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e
|
||||
|
||||
@@ -4,6 +4,7 @@ KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figu
|
||||
|
||||
|
||||
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
|
||||
# Removes large/redundant fields from metadata dict.
|
||||
metadata = {
|
||||
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
|
||||
}
|
||||
@@ -13,16 +14,15 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
|
||||
def process_metadata(
|
||||
metadata: dict[str, any],
|
||||
) -> dict[str, any]:
|
||||
# Removes large fields and converts non-serializable types (datetime, list, dict) to strings.
|
||||
result = {}
|
||||
for key, value in metadata.items():
|
||||
# Remove large fields
|
||||
# Skip large fields
|
||||
if key in KEYS_TO_EXCLUDE:
|
||||
del metadata[key]
|
||||
|
||||
continue
|
||||
# Convert non-serializable fields to strings
|
||||
if (
|
||||
isinstance(value, datetime)
|
||||
or isinstance(value, list)
|
||||
or isinstance(value, dict)
|
||||
):
|
||||
metadata[key] = str(value)
|
||||
return metadata
|
||||
if isinstance(value, (datetime, list, dict)):
|
||||
result[key] = str(value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import Request
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +31,7 @@ def search_external(
|
||||
|
||||
chat_id = getattr(request.state, "chat_id", None)
|
||||
if chat_id:
|
||||
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
|
||||
|
||||
response = requests.post(
|
||||
external_url,
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Optional, List
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import requests
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ class URLProcessingMixin:
|
||||
|
||||
def _safe_process_url_sync(self, url: str) -> bool:
|
||||
"""Synchronous version of safety checks."""
|
||||
if self.verify_ssl and not self._verify_ssl_cert(url):
|
||||
if self.verify_ssl and not verify_ssl_cert(url):
|
||||
raise ValueError(f"SSL certificate verification failed for {url}")
|
||||
self._sync_wait_for_rate_limit()
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, List
|
||||
|
||||
import requests
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def xml_element_contents_to_string(element: Element) -> str:
|
||||
buffer = [element.text if element.text else ""]
|
||||
|
||||
for child in element:
|
||||
buffer.append(xml_element_contents_to_string(child))
|
||||
|
||||
buffer.append(element.tail if element.tail else "")
|
||||
|
||||
return "".join(buffer)
|
||||
|
||||
|
||||
def search_yandex(
|
||||
request: Request,
|
||||
yandex_search_url: str,
|
||||
yandex_search_api_key: str,
|
||||
yandex_search_config: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[List[str]] = None,
|
||||
user=None,
|
||||
) -> List[SearchResult]:
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
|
||||
"Authorization": f"Api-Key {yandex_search_api_key}",
|
||||
}
|
||||
|
||||
if user is not None:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
chat_id = getattr(request.state, "chat_id", None)
|
||||
if chat_id:
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
|
||||
|
||||
payload = {} if yandex_search_config == "" else json.loads(yandex_search_config)
|
||||
|
||||
if type(payload.get("query", None)) != dict:
|
||||
payload["query"] = {}
|
||||
|
||||
if "searchType" not in payload["query"]:
|
||||
payload["query"]["searchType"] = "SEARCH_TYPE_RU"
|
||||
|
||||
payload["query"]["queryText"] = query
|
||||
|
||||
if type(payload.get("groupSpec", None)) != dict:
|
||||
payload["groupSpec"] = {}
|
||||
|
||||
if "groupMode" not in payload["groupSpec"]:
|
||||
payload["groupSpec"]["groupMode"] = "GROUP_MODE_DEEP"
|
||||
|
||||
payload["groupSpec"]["groupsOnPage"] = count
|
||||
payload["groupSpec"]["docsInGroup"] = 1
|
||||
|
||||
response = requests.post(
|
||||
(
|
||||
"https://searchapi.api.cloud.yandex.net/v2/web/search"
|
||||
if yandex_search_url == ""
|
||||
else yandex_search_url
|
||||
),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
response_body = response.json()
|
||||
if "rawData" not in response_body:
|
||||
raise Exception(f"No `rawData` in response body: {response_body}")
|
||||
|
||||
search_result_body_bytes = base64.decodebytes(
|
||||
bytes(response_body["rawData"], "utf-8")
|
||||
)
|
||||
|
||||
doc_root = ET.parse(io.BytesIO(search_result_body_bytes))
|
||||
|
||||
results = []
|
||||
|
||||
for group in doc_root.findall("response/results/grouping/group"):
|
||||
results.append(
|
||||
{
|
||||
"url": xml_element_contents_to_string(group.find("doc/url")).strip(
|
||||
"\n"
|
||||
),
|
||||
"title": xml_element_contents_to_string(
|
||||
group.find("doc/title")
|
||||
).strip("\n"),
|
||||
"snippet": xml_element_contents_to_string(
|
||||
group.find("doc/passages/passage")
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
results = [
|
||||
SearchResult(
|
||||
link=result.get("url"),
|
||||
title=result.get("title"),
|
||||
snippet=result.get("snippet"),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
|
||||
log.info(f"Yandex search results: {results}")
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
log.error(f"Error in search: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from starlette.datastructures import Headers
|
||||
from fastapi import FastAPI
|
||||
|
||||
result = search_yandex(
|
||||
Request(
|
||||
{
|
||||
"type": "http",
|
||||
"asgi.version": "3.0",
|
||||
"asgi.spec_version": "2.0",
|
||||
"method": "GET",
|
||||
"path": "/internal",
|
||||
"query_string": b"",
|
||||
"headers": Headers({}).raw,
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("127.0.0.1", 80),
|
||||
"scheme": "http",
|
||||
"app": FastAPI(),
|
||||
},
|
||||
None,
|
||||
),
|
||||
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
|
||||
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
|
||||
os.environ.get(
|
||||
"YANDEX_WEB_SEARCH_CONFIG", '{"query": {"searchType": "SEARCH_TYPE_COM"}}'
|
||||
),
|
||||
"TOP movies of the past year",
|
||||
3,
|
||||
)
|
||||
|
||||
print(result)
|
||||
@@ -0,0 +1,454 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.models.chat_messages import ChatMessages, ChatMessageModel
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.feedbacks import Feedbacks
|
||||
from open_webui.utils.auth import get_admin_user
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
####################
|
||||
# Response Models
|
||||
####################
|
||||
|
||||
|
||||
class ModelAnalyticsEntry(BaseModel):
|
||||
model_id: str
|
||||
count: int
|
||||
|
||||
|
||||
class ModelAnalyticsResponse(BaseModel):
|
||||
models: list[ModelAnalyticsEntry]
|
||||
|
||||
|
||||
class UserAnalyticsEntry(BaseModel):
|
||||
user_id: str
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
count: int
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
class UserAnalyticsResponse(BaseModel):
|
||||
users: list[UserAnalyticsEntry]
|
||||
|
||||
|
||||
####################
|
||||
# Endpoints
|
||||
####################
|
||||
|
||||
|
||||
@router.get("/models", response_model=ModelAnalyticsResponse)
|
||||
async def get_model_analytics(
|
||||
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
|
||||
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
|
||||
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get message counts per model."""
|
||||
counts = ChatMessages.get_message_count_by_model(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
models = [
|
||||
ModelAnalyticsEntry(model_id=model_id, count=count)
|
||||
for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
|
||||
]
|
||||
return ModelAnalyticsResponse(models=models)
|
||||
|
||||
|
||||
@router.get("/users", response_model=UserAnalyticsResponse)
|
||||
async def get_user_analytics(
|
||||
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
|
||||
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
|
||||
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
|
||||
limit: int = Query(50, description="Max users to return"),
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get message counts and token usage per user with user info."""
|
||||
counts = ChatMessages.get_message_count_by_user(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
token_usage = ChatMessages.get_token_usage_by_user(
|
||||
start_date=start_date, end_date=end_date, db=db
|
||||
)
|
||||
|
||||
# Get user info for top users
|
||||
top_user_ids = [
|
||||
uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]
|
||||
]
|
||||
user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)}
|
||||
|
||||
users = []
|
||||
for user_id in top_user_ids:
|
||||
u = user_info.get(user_id)
|
||||
tokens = token_usage.get(user_id, {})
|
||||
users.append(
|
||||
UserAnalyticsEntry(
|
||||
user_id=user_id,
|
||||
name=u.name if u else None,
|
||||
email=u.email if u else None,
|
||||
count=counts[user_id],
|
||||
input_tokens=tokens.get("input_tokens", 0),
|
||||
output_tokens=tokens.get("output_tokens", 0),
|
||||
total_tokens=tokens.get("total_tokens", 0),
|
||||
)
|
||||
)
|
||||
|
||||
return UserAnalyticsResponse(users=users)
|
||||
|
||||
|
||||
@router.get("/messages", response_model=list[ChatMessageModel])
|
||||
async def get_messages(
|
||||
model_id: Optional[str] = Query(None, description="Filter by model ID"),
|
||||
user_id: Optional[str] = Query(None, description="Filter by user ID"),
|
||||
chat_id: Optional[str] = Query(None, description="Filter by chat ID"),
|
||||
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
|
||||
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
|
||||
skip: int = Query(0),
|
||||
limit: int = Query(50, le=100),
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Query messages with filters."""
|
||||
if chat_id:
|
||||
return ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db)
|
||||
elif model_id:
|
||||
return ChatMessages.get_messages_by_model_id(
|
||||
model_id=model_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
elif user_id:
|
||||
return ChatMessages.get_messages_by_user_id(
|
||||
user_id=user_id, skip=skip, limit=limit, db=db
|
||||
)
|
||||
else:
|
||||
# Return empty if no filter specified
|
||||
return []
|
||||
|
||||
|
||||
class SummaryResponse(BaseModel):
|
||||
total_messages: int
|
||||
total_chats: int
|
||||
total_models: int
|
||||
total_users: int
|
||||
|
||||
|
||||
@router.get("/summary", response_model=SummaryResponse)
|
||||
async def get_summary(
|
||||
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
|
||||
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
|
||||
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get summary statistics for the dashboard."""
|
||||
model_counts = ChatMessages.get_message_count_by_model(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
user_counts = ChatMessages.get_message_count_by_user(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
chat_counts = ChatMessages.get_message_count_by_chat(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
|
||||
return SummaryResponse(
|
||||
total_messages=sum(model_counts.values()),
|
||||
total_chats=len(chat_counts),
|
||||
total_models=len(model_counts),
|
||||
total_users=len(user_counts),
|
||||
)
|
||||
|
||||
|
||||
class DailyStatsEntry(BaseModel):
|
||||
date: str
|
||||
models: dict[str, int]
|
||||
|
||||
|
||||
class DailyStatsResponse(BaseModel):
|
||||
data: list[DailyStatsEntry]
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyStatsResponse)
|
||||
async def get_daily_stats(
|
||||
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
|
||||
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
|
||||
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
|
||||
granularity: str = Query("daily", description="Granularity: 'hourly' or 'daily'"),
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get message counts grouped by model for time-series chart."""
|
||||
if granularity == "hourly":
|
||||
counts = ChatMessages.get_hourly_message_counts_by_model(
|
||||
start_date=start_date, end_date=end_date, db=db
|
||||
)
|
||||
else:
|
||||
counts = ChatMessages.get_daily_message_counts_by_model(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
return DailyStatsResponse(
|
||||
data=[
|
||||
DailyStatsEntry(date=date, models=models)
|
||||
for date, models in sorted(counts.items())
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TokenUsageEntry(BaseModel):
|
||||
model_id: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
total_tokens: int
|
||||
message_count: int
|
||||
|
||||
|
||||
class TokenUsageResponse(BaseModel):
|
||||
models: list[TokenUsageEntry]
|
||||
total_input_tokens: int
|
||||
total_output_tokens: int
|
||||
total_tokens: int
|
||||
|
||||
|
||||
@router.get("/tokens", response_model=TokenUsageResponse)
|
||||
async def get_token_usage(
|
||||
start_date: Optional[int] = Query(None),
|
||||
end_date: Optional[int] = Query(None),
|
||||
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get token usage aggregated by model."""
|
||||
usage = ChatMessages.get_token_usage_by_model(
|
||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
||||
)
|
||||
|
||||
models = [
|
||||
TokenUsageEntry(model_id=model_id, **data)
|
||||
for model_id, data in sorted(usage.items(), key=lambda x: -x[1]["total_tokens"])
|
||||
]
|
||||
|
||||
total_input = sum(m.input_tokens for m in models)
|
||||
total_output = sum(m.output_tokens for m in models)
|
||||
|
||||
return TokenUsageResponse(
|
||||
models=models,
|
||||
total_input_tokens=total_input,
|
||||
total_output_tokens=total_output,
|
||||
total_tokens=total_input + total_output,
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# Model Chats Browser
|
||||
####################
|
||||
|
||||
|
||||
class ModelChatEntry(BaseModel):
|
||||
chat_id: str
|
||||
user_id: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
first_message: Optional[str] = None
|
||||
updated_at: int
|
||||
|
||||
|
||||
class ModelChatsResponse(BaseModel):
|
||||
chats: list[ModelChatEntry]
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/chats", response_model=ModelChatsResponse)
|
||||
async def get_model_chats(
|
||||
model_id: str,
|
||||
start_date: Optional[int] = Query(None),
|
||||
end_date: Optional[int] = Query(None),
|
||||
skip: int = Query(0),
|
||||
limit: int = Query(50, le=100),
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get chats that used a specific model, with preview and feedback info."""
|
||||
|
||||
# Get chat IDs that used this model
|
||||
chat_ids = ChatMessages.get_chat_ids_by_model_id(
|
||||
model_id=model_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if not chat_ids:
|
||||
return ModelChatsResponse(chats=[], total=0)
|
||||
|
||||
# Get chat details from messages only
|
||||
chats_data = []
|
||||
for chat_id in chat_ids:
|
||||
messages = ChatMessages.get_messages_by_chat_id(chat_id, db=db)
|
||||
if not messages:
|
||||
continue
|
||||
|
||||
# Get user_id from first user message
|
||||
first_user_msg = next((m for m in messages if m.role == "user"), None)
|
||||
user_id = first_user_msg.user_id if first_user_msg else None
|
||||
|
||||
# Extract first message content as preview
|
||||
first_message = None
|
||||
if first_user_msg and first_user_msg.content:
|
||||
content = first_user_msg.content
|
||||
if isinstance(content, str):
|
||||
first_message = content[:200]
|
||||
elif isinstance(content, list):
|
||||
text_parts = [b.get("text", "") for b in content if isinstance(b, dict)]
|
||||
first_message = " ".join(text_parts)[:200]
|
||||
|
||||
# Get user info
|
||||
user_name = None
|
||||
if user_id:
|
||||
user_info = Users.get_user_by_id(user_id, db=db)
|
||||
user_name = user_info.name if user_info else None
|
||||
|
||||
# Timestamps from messages
|
||||
updated_at = max(m.created_at for m in messages) if messages else 0
|
||||
|
||||
chats_data.append(
|
||||
ModelChatEntry(
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
first_message=first_message,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
return ModelChatsResponse(chats=chats_data, total=len(chats_data))
|
||||
|
||||
|
||||
####################
|
||||
# Model Overview
|
||||
####################
|
||||
|
||||
|
||||
class HistoryEntry(BaseModel):
|
||||
date: str
|
||||
won: int = 0
|
||||
lost: int = 0
|
||||
|
||||
|
||||
class TagEntry(BaseModel):
|
||||
tag: str
|
||||
count: int
|
||||
|
||||
|
||||
class ModelOverviewResponse(BaseModel):
|
||||
history: list[HistoryEntry]
|
||||
tags: list[TagEntry]
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/overview", response_model=ModelOverviewResponse)
|
||||
async def get_model_overview(
|
||||
model_id: str,
|
||||
days: int = Query(30, description="Number of days of history (0 for all)"),
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get model overview with feedback history and chat tags."""
|
||||
|
||||
# Get chat IDs that used this model
|
||||
chat_ids = ChatMessages.get_chat_ids_by_model_id(
|
||||
model_id=model_id,
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
skip=0,
|
||||
limit=10000, # Get all chats
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Get feedback history per day
|
||||
history_counts: dict[str, dict] = defaultdict(lambda: {"won": 0, "lost": 0})
|
||||
|
||||
# Calculate start date for history
|
||||
now = datetime.now()
|
||||
start_dt = None
|
||||
if days > 0:
|
||||
start_dt = now - timedelta(days=days)
|
||||
|
||||
for chat_id in chat_ids:
|
||||
feedbacks = Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db)
|
||||
for fb in feedbacks:
|
||||
if fb.data and "rating" in fb.data:
|
||||
rating = fb.data["rating"]
|
||||
fb_date = datetime.fromtimestamp(fb.created_at)
|
||||
|
||||
# Filter by date range
|
||||
if start_dt and fb_date < start_dt:
|
||||
continue
|
||||
|
||||
date_str = fb_date.strftime("%Y-%m-%d")
|
||||
if rating == 1:
|
||||
history_counts[date_str]["won"] += 1
|
||||
elif rating == -1:
|
||||
history_counts[date_str]["lost"] += 1
|
||||
|
||||
# Fill in missing days
|
||||
history = []
|
||||
if history_counts or days > 0:
|
||||
end_dt = now
|
||||
if days > 0:
|
||||
current = start_dt
|
||||
elif history_counts:
|
||||
# Find earliest date
|
||||
min_date = min(history_counts.keys())
|
||||
current = datetime.strptime(min_date, "%Y-%m-%d")
|
||||
else:
|
||||
current = now
|
||||
|
||||
while current <= end_dt:
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
counts = history_counts.get(date_str, {"won": 0, "lost": 0})
|
||||
history.append(
|
||||
HistoryEntry(
|
||||
date=date_str,
|
||||
won=counts["won"],
|
||||
lost=counts["lost"],
|
||||
)
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
|
||||
# Get chat tags
|
||||
tag_counts: dict[str, int] = defaultdict(int)
|
||||
for chat_id in chat_ids:
|
||||
chat = Chats.get_chat_by_id(chat_id, db=db)
|
||||
if chat and chat.meta:
|
||||
for tag in chat.meta.get("tags", []):
|
||||
tag_counts[tag] += 1
|
||||
|
||||
# Sort by count and take top 10
|
||||
tags = [
|
||||
TagEntry(tag=tag, count=count)
|
||||
for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]
|
||||
]
|
||||
|
||||
return ModelOverviewResponse(history=history, tags=tags)
|
||||
@@ -53,11 +53,11 @@ from open_webui.env import (
|
||||
ENV,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
DEVICE_TYPE,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Constants
|
||||
@@ -334,8 +334,8 @@ def load_speech_pipeline(request):
|
||||
async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
if request.app.state.config.TTS_ENGINE == "":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_permission(
|
||||
@@ -639,12 +639,14 @@ def transcription_handler(request, file_path, metadata, user=None):
|
||||
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
r = requests.post(
|
||||
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
|
||||
headers=headers,
|
||||
files={"file": (filename, open(file_path, "rb"))},
|
||||
data=payload,
|
||||
)
|
||||
with open(file_path, "rb") as audio_file:
|
||||
r = requests.post(
|
||||
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
|
||||
headers=headers,
|
||||
files={"file": (filename, audio_file)},
|
||||
data=payload,
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
# Successful transcription
|
||||
@@ -704,6 +706,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
@@ -815,6 +818,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
||||
headers={
|
||||
"Ocp-Apim-Subscription-Key": api_key,
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
@@ -954,6 +958,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
@@ -997,6 +1002,7 @@ def transcription_handler(request, file_path, metadata, user=None):
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
@@ -1169,12 +1175,6 @@ def transcription(
|
||||
language: Optional[str] = Form(None),
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if request.app.state.config.STT_ENGINE == "":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "chat.stt", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
@@ -1246,7 +1246,8 @@ def get_available_models(request: Request) -> list[dict]:
|
||||
):
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models"
|
||||
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models",
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
@@ -1292,7 +1293,8 @@ def get_available_voices(request) -> dict:
|
||||
):
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices"
|
||||
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices",
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
@@ -1336,7 +1338,9 @@ def get_available_voices(request) -> dict:
|
||||
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
response = requests.get(
|
||||
url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST
|
||||
)
|
||||
response.raise_for_status()
|
||||
voices = response.json()
|
||||
|
||||
@@ -1368,6 +1372,7 @@ def get_elevenlabs_voices(api_key: str) -> dict:
|
||||
"xi-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
voices_data = response.json()
|
||||
|
||||
+250
-167
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import re
|
||||
import uuid
|
||||
import time
|
||||
@@ -19,6 +20,7 @@ from open_webui.models.auths import (
|
||||
UpdatePasswordForm,
|
||||
)
|
||||
from open_webui.models.users import (
|
||||
UserModel,
|
||||
UserProfileImageResponse,
|
||||
Users,
|
||||
UpdateProfileForm,
|
||||
@@ -37,6 +39,8 @@ from open_webui.env import (
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
||||
ENABLE_INITIAL_ADMIN_SIGNUP,
|
||||
ENABLE_OAUTH_TOKEN_EXCHANGE,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response, JSONResponse
|
||||
@@ -45,6 +49,8 @@ from open_webui.config import (
|
||||
ENABLE_OAUTH_SIGNUP,
|
||||
ENABLE_LDAP,
|
||||
ENABLE_PASSWORD_AUTH,
|
||||
OAUTH_PROVIDERS,
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -87,6 +93,63 @@ signin_rate_limiter = RateLimiter(
|
||||
redis_client=get_redis_client(), limit=5 * 3, window=60 * 3
|
||||
)
|
||||
|
||||
|
||||
def create_session_response(
|
||||
request: Request, user, db, response: Response = None, set_cookie: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Create JWT token and build session response for a user.
|
||||
Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
user: User object
|
||||
db: Database session
|
||||
response: FastAPI response object (required if set_cookie is True)
|
||||
set_cookie: Whether to set the auth cookie on the response
|
||||
"""
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
if set_cookie and response:
|
||||
datetime_expires_at = (
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else None
|
||||
)
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=datetime_expires_at,
|
||||
httponly=True,
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS, db=db
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": f"/api/v1/users/{user.id}/profile/image",
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# GetSessionUser
|
||||
############################
|
||||
@@ -315,7 +378,7 @@ async def ldap_auth(
|
||||
auto_bind="NONE",
|
||||
authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS",
|
||||
)
|
||||
if not connection_app.bind():
|
||||
if not await asyncio.to_thread(connection_app.bind):
|
||||
raise HTTPException(400, detail="Application account bind failed")
|
||||
|
||||
ENABLE_LDAP_GROUP_MANAGEMENT = (
|
||||
@@ -336,7 +399,8 @@ async def ldap_auth(
|
||||
)
|
||||
log.info(f"LDAP search attributes: {search_attributes}")
|
||||
|
||||
search_success = connection_app.search(
|
||||
search_success = await asyncio.to_thread(
|
||||
connection_app.search,
|
||||
search_base=LDAP_SEARCH_BASE,
|
||||
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
|
||||
attributes=search_attributes,
|
||||
@@ -440,7 +504,7 @@ async def ldap_auth(
|
||||
auto_bind="NONE",
|
||||
authentication="SIMPLE",
|
||||
)
|
||||
if not connection_user.bind():
|
||||
if not await asyncio.to_thread(connection_user.bind):
|
||||
raise HTTPException(400, "Authentication failed.")
|
||||
|
||||
user = Users.get_user_by_email(email, db=db)
|
||||
@@ -482,36 +546,6 @@ async def ldap_auth(
|
||||
user = Auths.authenticate_user_by_email(email, db=db)
|
||||
|
||||
if user:
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=(
|
||||
datetime.datetime.fromtimestamp(
|
||||
expires_at, datetime.timezone.utc
|
||||
)
|
||||
if expires_at
|
||||
else None
|
||||
),
|
||||
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS, db=db
|
||||
)
|
||||
|
||||
if (
|
||||
user.role != "admin"
|
||||
and ENABLE_LDAP_GROUP_MANAGEMENT
|
||||
@@ -527,17 +561,9 @@ async def ldap_auth(
|
||||
except Exception as e:
|
||||
log.error(f"Failed to sync groups for user {user.id}: {e}")
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
return create_session_response(
|
||||
request, user, db, response, set_cookie=True
|
||||
)
|
||||
else:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
else:
|
||||
@@ -580,10 +606,11 @@ async def signin(
|
||||
pass
|
||||
|
||||
if not Users.get_user_by_email(email.lower(), db=db):
|
||||
await signup(
|
||||
await signup_handler(
|
||||
request,
|
||||
response,
|
||||
SignupForm(email=email, password=str(uuid.uuid4()), name=name),
|
||||
email,
|
||||
str(uuid.uuid4()),
|
||||
name,
|
||||
db=db,
|
||||
)
|
||||
|
||||
@@ -611,10 +638,11 @@ async def signin(
|
||||
if Users.has_users(db=db):
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
|
||||
|
||||
await signup(
|
||||
await signup_handler(
|
||||
request,
|
||||
response,
|
||||
SignupForm(email=admin_email, password=admin_password, name="User"),
|
||||
admin_email,
|
||||
admin_password,
|
||||
"User",
|
||||
db=db,
|
||||
)
|
||||
|
||||
@@ -646,48 +674,7 @@ async def signin(
|
||||
)
|
||||
|
||||
if user:
|
||||
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
datetime_expires_at = (
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else None
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=datetime_expires_at,
|
||||
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS, db=db
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
return create_session_response(request, user, db, response, set_cookie=True)
|
||||
else:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
@@ -697,6 +684,62 @@ async def signin(
|
||||
############################
|
||||
|
||||
|
||||
async def signup_handler(
|
||||
request: Request,
|
||||
email: str,
|
||||
password: str,
|
||||
name: str,
|
||||
profile_image_url: str = "/user.png",
|
||||
*,
|
||||
db: Session,
|
||||
) -> UserModel:
|
||||
"""
|
||||
Core user-creation logic shared by the signup endpoint and
|
||||
trusted-header / no-auth auto-registration flows.
|
||||
|
||||
Returns the newly created UserModel.
|
||||
Raises HTTPException on failure.
|
||||
"""
|
||||
has_users = Users.has_users(db=db)
|
||||
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
user = Auths.insert_new_auth(
|
||||
email=email.lower(),
|
||||
password=hashed,
|
||||
name=name,
|
||||
profile_image_url=profile_image_url,
|
||||
role=role,
|
||||
db=db,
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
"action": "signup",
|
||||
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
"user": user.model_dump_json(exclude_none=True),
|
||||
},
|
||||
)
|
||||
|
||||
if not has_users:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
apply_default_group_assignment(
|
||||
request.app.state.config.DEFAULT_GROUP_ID,
|
||||
user.id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/signup", response_model=SessionUserResponse)
|
||||
async def signup(
|
||||
request: Request,
|
||||
@@ -735,84 +778,17 @@ async def signup(
|
||||
except Exception as e:
|
||||
raise HTTPException(400, detail=str(e))
|
||||
|
||||
hashed = get_password_hash(form_data.password)
|
||||
|
||||
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
|
||||
user = Auths.insert_new_auth(
|
||||
form_data.email.lower(),
|
||||
hashed,
|
||||
user = await signup_handler(
|
||||
request,
|
||||
form_data.email,
|
||||
form_data.password,
|
||||
form_data.name,
|
||||
form_data.profile_image_url,
|
||||
role,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if user:
|
||||
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
||||
expires_at = None
|
||||
if expires_delta:
|
||||
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
||||
|
||||
token = create_token(
|
||||
data={"id": user.id},
|
||||
expires_delta=expires_delta,
|
||||
)
|
||||
|
||||
datetime_expires_at = (
|
||||
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
|
||||
if expires_at
|
||||
else None
|
||||
)
|
||||
|
||||
# Set the cookie token
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
expires=datetime_expires_at,
|
||||
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
"action": "signup",
|
||||
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
"user": user.model_dump_json(exclude_none=True),
|
||||
},
|
||||
)
|
||||
|
||||
user_permissions = get_permissions(
|
||||
user.id, request.app.state.config.USER_PERMISSIONS, db=db
|
||||
)
|
||||
|
||||
if not has_users:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
apply_default_group_assignment(
|
||||
request.app.state.config.DEFAULT_GROUP_ID,
|
||||
user.id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": expires_at,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"permissions": user_permissions,
|
||||
}
|
||||
else:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
return create_session_response(request, user, db, response, set_cookie=True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as err:
|
||||
log.error(f"Signup error: {str(err)}")
|
||||
raise HTTPException(500, detail="An internal error occurred during signup.")
|
||||
@@ -950,10 +926,12 @@ async def add_user(
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"profile_image_url": f"/api/v1/users/{user.id}/profile/image",
|
||||
}
|
||||
else:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as err:
|
||||
log.error(f"Add user error: {str(err)}")
|
||||
raise HTTPException(
|
||||
@@ -1283,3 +1261,108 @@ async def get_api_key(
|
||||
}
|
||||
else:
|
||||
raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
|
||||
|
||||
|
||||
############################
|
||||
# Token Exchange
|
||||
############################
|
||||
|
||||
|
||||
class TokenExchangeForm(BaseModel):
|
||||
token: str # OAuth access token from external provider
|
||||
|
||||
|
||||
@router.post("/oauth/{provider}/token/exchange", response_model=SessionUserResponse)
|
||||
async def token_exchange(
|
||||
request: Request,
|
||||
response: Response,
|
||||
provider: str,
|
||||
form_data: TokenExchangeForm,
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Exchange an external OAuth provider token for an OpenWebUI JWT.
|
||||
This endpoint is disabled by default. Set ENABLE_OAUTH_TOKEN_EXCHANGE=True to enable.
|
||||
"""
|
||||
if not ENABLE_OAUTH_TOKEN_EXCHANGE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Token exchange is disabled",
|
||||
)
|
||||
|
||||
provider = provider.lower()
|
||||
|
||||
# Check if provider is configured
|
||||
if provider not in OAUTH_PROVIDERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Provider '{provider}' is not configured",
|
||||
)
|
||||
# Get the OAuth client for this provider
|
||||
oauth_manager = request.app.state.oauth_manager
|
||||
client = oauth_manager.get_client(provider)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"OAuth client for '{provider}' not found",
|
||||
)
|
||||
|
||||
# Validate the token by calling the userinfo endpoint
|
||||
try:
|
||||
token_data = {"access_token": form_data.token, "token_type": "Bearer"}
|
||||
user_data = await client.userinfo(token=token_data)
|
||||
|
||||
if not user_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid token or unable to fetch user info",
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Token exchange failed for provider {provider}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid token or unable to validate with provider",
|
||||
)
|
||||
|
||||
# Extract user information from the token claims
|
||||
email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM
|
||||
username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM
|
||||
|
||||
# Get sub claim
|
||||
sub = user_data.get(
|
||||
request.app.state.config.OAUTH_SUB_CLAIM
|
||||
or OAUTH_PROVIDERS[provider].get("sub_claim", "sub")
|
||||
)
|
||||
if not sub:
|
||||
log.warning(f"Token exchange failed: sub claim missing from user data")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token missing required 'sub' claim",
|
||||
)
|
||||
|
||||
email = user_data.get(email_claim, "")
|
||||
if not email:
|
||||
log.warning(f"Token exchange failed: email claim missing from user data")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token missing required email claim",
|
||||
)
|
||||
email = email.lower()
|
||||
|
||||
# Try to find the user by OAuth sub
|
||||
user = Users.get_user_by_oauth_sub(provider, sub, db=db)
|
||||
|
||||
if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
|
||||
# Try to find by email if merge is enabled
|
||||
user = Users.get_user_by_email(email, db=db)
|
||||
if user:
|
||||
# Link the OAuth sub to this user
|
||||
Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User not found. Please sign in via the web interface first.",
|
||||
)
|
||||
|
||||
return create_session_response(request, user, db)
|
||||
|
||||
@@ -22,6 +22,7 @@ from open_webui.models.users import (
|
||||
UserListResponse,
|
||||
UserModelResponse,
|
||||
Users,
|
||||
UserModel,
|
||||
UserNameResponse,
|
||||
)
|
||||
|
||||
@@ -35,6 +36,7 @@ from open_webui.models.channels import (
|
||||
ChannelWebhookModel,
|
||||
ChannelWebhookForm,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.models.messages import (
|
||||
Messages,
|
||||
MessageModel,
|
||||
@@ -59,12 +61,7 @@ from open_webui.utils.chat import generate_chat_completion
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import (
|
||||
has_access,
|
||||
get_users_with_access,
|
||||
get_permitted_group_and_user_ids,
|
||||
has_permission,
|
||||
)
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from open_webui.utils.channels import extract_mentions, replace_mentions
|
||||
from open_webui.internal.db import get_session
|
||||
@@ -75,12 +72,72 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def channel_has_access(
|
||||
user_id: str,
|
||||
channel: ChannelModel,
|
||||
permission: str = "read",
|
||||
strict: bool = True,
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
if AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="channel",
|
||||
resource_id=channel.id,
|
||||
permission=permission,
|
||||
db=db,
|
||||
):
|
||||
return True
|
||||
|
||||
if (
|
||||
not strict
|
||||
and permission == "write"
|
||||
and has_public_read_access_grant(channel.access_grants)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_channel_users_with_access(
|
||||
channel: ChannelModel, permission: str = "read", db: Optional[Session] = None
|
||||
):
|
||||
return AccessGrants.get_users_with_access(
|
||||
resource_type="channel",
|
||||
resource_id=channel.id,
|
||||
permission=permission,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
def get_channel_permitted_group_and_user_ids(
|
||||
channel: ChannelModel, permission: str = "read"
|
||||
) -> Optional[dict[str, list[str]]]:
|
||||
if permission == "read" and has_public_read_access_grant(channel.access_grants):
|
||||
return None
|
||||
|
||||
user_ids = []
|
||||
group_ids = []
|
||||
|
||||
for grant in channel.access_grants:
|
||||
if grant.permission != permission:
|
||||
continue
|
||||
if grant.principal_type == "group":
|
||||
group_ids.append(grant.principal_id)
|
||||
elif grant.principal_type == "user" and grant.principal_id != "*":
|
||||
user_ids.append(grant.principal_id)
|
||||
|
||||
return {
|
||||
"user_ids": list(dict.fromkeys(user_ids)),
|
||||
"group_ids": list(dict.fromkeys(group_ids)),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# Channels Enabled Dependency
|
||||
############################
|
||||
|
||||
|
||||
def check_channels_access(request: Request):
|
||||
def check_channels_access(request: Request, user: Optional[UserModel] = None):
|
||||
"""Dependency to ensure channels are globally enabled."""
|
||||
if not request.app.state.config.ENABLE_CHANNELS:
|
||||
raise HTTPException(
|
||||
@@ -88,6 +145,15 @@ def check_channels_access(request: Request):
|
||||
detail="Channels are not enabled",
|
||||
)
|
||||
|
||||
if user:
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetChatList
|
||||
@@ -108,14 +174,7 @@ async def get_channels(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channels = Channels.get_channels_by_user_id(user.id, db=db)
|
||||
channel_list = []
|
||||
@@ -145,7 +204,7 @@ async def get_channels(
|
||||
UserIdNameStatusResponse(
|
||||
**{
|
||||
**user.model_dump(),
|
||||
"is_active": Users.is_user_active(user.id, db=db),
|
||||
"is_active": Users.is_active(user),
|
||||
}
|
||||
)
|
||||
for user in Users.get_users_by_user_ids(user_ids, db=db)
|
||||
@@ -188,15 +247,7 @@ async def get_dm_channel_by_user_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
check_channels_access(request, user)
|
||||
try:
|
||||
existing_channel = Channels.get_dm_channel_by_user_ids(
|
||||
[user.id, user_id], db=db
|
||||
@@ -268,14 +319,7 @@ async def create_new_channel(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
if form_data.type not in ["group", "dm"] and user.role != "admin":
|
||||
# Only admins can create standard channels (joined by default)
|
||||
@@ -355,7 +399,7 @@ async def get_channel_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
check_channels_access(request, user)
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
@@ -380,7 +424,7 @@ async def get_channel_by_id(
|
||||
UserIdNameStatusResponse(
|
||||
**{
|
||||
**user.model_dump(),
|
||||
"is_active": Users.is_user_active(user.id, db=db),
|
||||
"is_active": Users.is_active(user),
|
||||
}
|
||||
)
|
||||
for user in Users.get_users_by_user_ids(user_ids, db=db)
|
||||
@@ -408,22 +452,22 @@ async def get_channel_by_id(
|
||||
}
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
write_access = has_access(
|
||||
write_access = channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
)
|
||||
|
||||
user_count = len(get_users_with_access("read", channel.access_control))
|
||||
user_count = len(get_channel_users_with_access(channel, "read", db=db))
|
||||
|
||||
channel_member = Channels.get_member_by_channel_and_user_id(
|
||||
channel.id, user.id, db=db
|
||||
@@ -467,7 +511,7 @@ async def get_channel_members_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
@@ -496,9 +540,7 @@ async def get_channel_members_by_id(
|
||||
|
||||
return {
|
||||
"users": [
|
||||
UserModelResponse(
|
||||
**user.model_dump(), is_active=Users.is_user_active(user.id, db=db)
|
||||
)
|
||||
UserModelResponse(**user.model_dump(), is_active=Users.is_active(user))
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
@@ -517,8 +559,8 @@ async def get_channel_members_by_id(
|
||||
filter["channel_id"] = channel.id
|
||||
else:
|
||||
filter["roles"] = ["!pending"]
|
||||
permitted_ids = get_permitted_group_and_user_ids(
|
||||
"read", channel.access_control
|
||||
permitted_ids = get_channel_permitted_group_and_user_ids(
|
||||
channel, permission="read"
|
||||
)
|
||||
if permitted_ids:
|
||||
filter["user_ids"] = permitted_ids.get("user_ids")
|
||||
@@ -531,9 +573,7 @@ async def get_channel_members_by_id(
|
||||
|
||||
return {
|
||||
"users": [
|
||||
UserModelResponse(
|
||||
**user.model_dump(), is_active=Users.is_user_active(user.id, db=db)
|
||||
)
|
||||
UserModelResponse(**user.model_dump(), is_active=Users.is_active(user))
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
@@ -593,15 +633,7 @@ async def add_members_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
check_channels_access(request, user)
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
@@ -643,14 +675,7 @@ async def remove_members_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
@@ -689,14 +714,7 @@ async def update_channel_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
@@ -731,14 +749,7 @@ async def delete_channel_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
check_channels_access(request, user)
|
||||
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
@@ -788,7 +799,7 @@ async def get_channel_messages(
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
check_channels_access(request)
|
||||
check_channels_access(request, user)
|
||||
channel = Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
@@ -801,8 +812,8 @@ async def get_channel_messages(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -878,8 +889,8 @@ async def get_pinned_channel_messages(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -936,7 +947,7 @@ async def get_pinned_channel_messages(
|
||||
async def send_notification(
|
||||
name, webui_url, channel, message, active_user_ids, db=None
|
||||
):
|
||||
users = get_users_with_access("read", channel.access_control)
|
||||
users = get_channel_users_with_access(channel, "read", db=db)
|
||||
|
||||
for user in users:
|
||||
if (user.id not in active_user_ids) and Channels.is_user_channel_member(
|
||||
@@ -1055,7 +1066,7 @@ async def model_response_handler(request, channel, message, user, db=None):
|
||||
f"{username}: {replace_mentions(thread_message.content)}"
|
||||
)
|
||||
|
||||
thread_message_files = thread_message.data.get("files", [])
|
||||
thread_message_files = (thread_message.data or {}).get("files", [])
|
||||
for file in thread_message_files:
|
||||
if file.get("type", "") == "image":
|
||||
images.append(file.get("url", ""))
|
||||
@@ -1163,10 +1174,10 @@ async def new_message_handler(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
@@ -1256,15 +1267,17 @@ async def post_new_message(
|
||||
|
||||
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
|
||||
|
||||
# NOTE: We intentionally do NOT pass db to background_handler.
|
||||
# Background tasks should manage their own short-lived sessions to avoid
|
||||
# holding database connections during slow operations (e.g., LLM calls).
|
||||
async def background_handler():
|
||||
await model_response_handler(request, channel, message, user, db)
|
||||
await model_response_handler(request, channel, message, user)
|
||||
await send_notification(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBUI_URL,
|
||||
channel,
|
||||
message,
|
||||
active_user_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
background_tasks.add_task(background_handler)
|
||||
@@ -1306,8 +1319,8 @@ async def get_channel_message(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -1360,8 +1373,8 @@ async def get_channel_message_data(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -1414,8 +1427,8 @@ async def pin_channel_message(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -1480,8 +1493,8 @@ async def get_channel_thread_messages(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -1565,9 +1578,7 @@ async def update_message_by_id(
|
||||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
)
|
||||
and not channel_has_access(user.id, channel, permission="read", db=db)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
@@ -1632,10 +1643,10 @@ async def add_reaction_to_message(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
@@ -1711,10 +1722,10 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
@@ -1806,10 +1817,10 @@ async def delete_message_by_id(
|
||||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(
|
||||
and not channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
)
|
||||
@@ -1872,13 +1883,9 @@ async def delete_message_by_id(
|
||||
|
||||
|
||||
@router.get("/webhooks/{webhook_id}/profile/image")
|
||||
async def get_webhook_profile_image(
|
||||
webhook_id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_user)):
|
||||
"""Get webhook profile image by webhook ID."""
|
||||
webhook = Channels.get_webhook_by_id(webhook_id, db=db)
|
||||
webhook = Channels.get_webhook_by_id(webhook_id)
|
||||
if not webhook:
|
||||
# Return default favicon if webhook not found
|
||||
return FileResponse(f"{STATIC_DIR}/favicon.png")
|
||||
|
||||
@@ -16,6 +16,7 @@ from open_webui.models.chats import (
|
||||
ChatResponse,
|
||||
Chats,
|
||||
ChatTitleIdResponse,
|
||||
SharedChatResponse,
|
||||
ChatStatsExport,
|
||||
AggregateChatStats,
|
||||
ChatBody,
|
||||
@@ -357,9 +358,7 @@ def _process_chat_for_export(chat) -> Optional[ChatStatsExport]:
|
||||
return None
|
||||
|
||||
|
||||
def calculate_chat_stats(
|
||||
user_id, skip=0, limit=10, filter=None, db: Optional[Session] = None
|
||||
):
|
||||
def calculate_chat_stats(user_id, skip=0, limit=10, filter=None):
|
||||
if filter is None:
|
||||
filter = {}
|
||||
|
||||
@@ -368,7 +367,6 @@ def calculate_chat_stats(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
filter=filter,
|
||||
db=db,
|
||||
)
|
||||
|
||||
chat_stats_export_list = []
|
||||
@@ -424,7 +422,6 @@ async def export_chat_stats(
|
||||
page: Optional[int] = 1,
|
||||
stream: bool = False,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# Check if the user has permission to share/export chats
|
||||
if (user.role != "admin") and (
|
||||
@@ -455,7 +452,7 @@ async def export_chat_stats(
|
||||
skip = (page - 1) * limit
|
||||
|
||||
chat_stats_export_list, total = await asyncio.to_thread(
|
||||
calculate_chat_stats, user.id, skip, limit, filter, db=db
|
||||
calculate_chat_stats, user.id, skip, limit, filter
|
||||
)
|
||||
|
||||
return ChatStatsExportList(
|
||||
@@ -862,6 +859,48 @@ async def unarchive_all_chats(
|
||||
return Chats.unarchive_all_chats_by_user_id(user.id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# GetSharedChats
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/shared", response_model=list[SharedChatResponse])
|
||||
async def get_shared_session_user_chat_list(
|
||||
page: Optional[int] = None,
|
||||
query: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if page is None:
|
||||
page = 1
|
||||
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if order_by:
|
||||
filter["order_by"] = order_by
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
chat_list = [
|
||||
SharedChatResponse(**chat.model_dump())
|
||||
for chat in Chats.get_shared_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
return chat_list
|
||||
|
||||
|
||||
############################
|
||||
# GetSharedChatById
|
||||
############################
|
||||
@@ -1092,9 +1131,9 @@ async def delete_chat_by_id(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
for tag in chat.meta.get("tags", []):
|
||||
if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 1:
|
||||
Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db)
|
||||
Chats.delete_orphan_tags_for_user(
|
||||
chat.meta.get("tags", []), user.id, threshold=1, db=db
|
||||
)
|
||||
|
||||
result = Chats.delete_chat_by_id(id, db=db)
|
||||
|
||||
@@ -1114,9 +1153,9 @@ async def delete_chat_by_id(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
for tag in chat.meta.get("tags", []):
|
||||
if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 1:
|
||||
Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db)
|
||||
Chats.delete_orphan_tags_for_user(
|
||||
chat.meta.get("tags", []), user.id, threshold=1, db=db
|
||||
)
|
||||
|
||||
result = Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
return result
|
||||
@@ -1278,21 +1317,13 @@ async def archive_chat_by_id(
|
||||
if chat:
|
||||
chat = Chats.toggle_chat_archive_by_id(id, db=db)
|
||||
|
||||
# Delete tags if chat is archived
|
||||
tag_ids = chat.meta.get("tags", [])
|
||||
if chat.archived:
|
||||
for tag_id in chat.meta.get("tags", []):
|
||||
if (
|
||||
Chats.count_chats_by_tag_name_and_user_id(tag_id, user.id, db=db)
|
||||
== 0
|
||||
):
|
||||
log.debug(f"deleting tag: {tag_id}")
|
||||
Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
|
||||
# Archived chats are excluded from count — clean up orphans
|
||||
Chats.delete_orphan_tags_for_user(tag_ids, user.id, db=db)
|
||||
else:
|
||||
for tag_id in chat.meta.get("tags", []):
|
||||
tag = Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db)
|
||||
if tag is None:
|
||||
log.debug(f"inserting tag: {tag_id}")
|
||||
tag = Tags.insert_new_tag(tag_id, user.id, db=db)
|
||||
# Unarchived — ensure tag rows exist
|
||||
Tags.ensure_tags_exist(tag_ids, user.id, db=db)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
else:
|
||||
@@ -1498,11 +1529,9 @@ async def delete_all_tags_by_id(
|
||||
):
|
||||
chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if chat:
|
||||
old_tags = chat.meta.get("tags", [])
|
||||
Chats.delete_all_tags_by_id_and_user_id(id, user.id, db=db)
|
||||
|
||||
for tag in chat.meta.get("tags", []):
|
||||
if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 0:
|
||||
Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db)
|
||||
Chats.delete_orphan_tags_for_user(old_tags, user.id, db=db)
|
||||
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -224,7 +224,7 @@ async def verify_tool_servers_config(
|
||||
try:
|
||||
if form_data.type == "mcp":
|
||||
if form_data.auth_type == "oauth_2.1":
|
||||
discovery_urls = get_discovery_urls(form_data.url)
|
||||
discovery_urls = await get_discovery_urls(form_data.url)
|
||||
for discovery_url in discovery_urls:
|
||||
log.debug(
|
||||
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
|
||||
|
||||
@@ -38,6 +38,7 @@ from open_webui.models.files import (
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
|
||||
from open_webui.routers.retrieval import ProcessFileForm, process_file
|
||||
@@ -47,7 +48,6 @@ from open_webui.storage.provider import Storage
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.misc import strict_match_mime_type
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -82,8 +82,13 @@ def has_access_to_file(
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.user_id == user.id or has_access(
|
||||
user.id, access_type, knowledge_base.access_control, user_group_ids, db=db
|
||||
if knowledge_base.user_id == user.id or 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
|
||||
|
||||
@@ -282,7 +287,11 @@ def upload_file_handler(
|
||||
},
|
||||
"meta": {
|
||||
"name": name,
|
||||
"content_type": file.content_type,
|
||||
"content_type": (
|
||||
file.content_type
|
||||
if isinstance(file.content_type, str)
|
||||
else None
|
||||
),
|
||||
"size": len(contents),
|
||||
"data": file_metadata,
|
||||
},
|
||||
@@ -332,6 +341,8 @@ def upload_file_handler(
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
|
||||
)
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
@@ -495,32 +506,35 @@ async def get_file_process_status(
|
||||
if stream:
|
||||
MAX_FILE_PROCESSING_DURATION = 3600 * 2
|
||||
|
||||
async def event_stream(file_item):
|
||||
if file_item:
|
||||
for _ in range(MAX_FILE_PROCESSING_DURATION):
|
||||
file_item = Files.get_file_by_id(file_item.id, db=db)
|
||||
if file_item:
|
||||
data = file_item.model_dump().get("data", {})
|
||||
status = data.get("status")
|
||||
async def event_stream(file_id):
|
||||
# NOTE: We intentionally do NOT capture the request's db session here.
|
||||
# Each poll creates its own short-lived session to avoid holding a
|
||||
# connection for hours. A WebSocket push would be more efficient.
|
||||
for _ in range(MAX_FILE_PROCESSING_DURATION):
|
||||
file_item = Files.get_file_by_id(file_id) # Creates own session
|
||||
if file_item:
|
||||
data = file_item.model_dump().get("data", {})
|
||||
status = data.get("status")
|
||||
|
||||
if status:
|
||||
event = {"status": status}
|
||||
if status == "failed":
|
||||
event["error"] = data.get("error")
|
||||
if status:
|
||||
event = {"status": status}
|
||||
if status == "failed":
|
||||
event["error"] = data.get("error")
|
||||
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
if status in ("completed", "failed"):
|
||||
break
|
||||
else:
|
||||
# Legacy
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
if status in ("completed", "failed"):
|
||||
break
|
||||
else:
|
||||
# Legacy
|
||||
break
|
||||
else:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(file),
|
||||
event_stream(file.id),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
else:
|
||||
@@ -572,7 +586,7 @@ class ContentForm(BaseModel):
|
||||
|
||||
|
||||
@router.post("/{id}/data/content/update")
|
||||
async def update_file_data_content_by_id(
|
||||
def update_file_data_content_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ContentForm,
|
||||
@@ -822,6 +836,23 @@ async def delete_file_by_id(
|
||||
or has_access_to_file(id, "write", user, db=db)
|
||||
):
|
||||
|
||||
# Clean up KB associations and embeddings before deleting
|
||||
knowledges = Knowledges.get_knowledges_by_file_id(id, db=db)
|
||||
for knowledge in knowledges:
|
||||
# Remove KB-file relationship
|
||||
Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db)
|
||||
# Clean KB embeddings (same logic as /knowledge/{id}/file/remove)
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
collection_name=knowledge.id, filter={"file_id": id}
|
||||
)
|
||||
if file.hash:
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
collection_name=knowledge.id, filter={"hash": file.hash}
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"KB embedding cleanup for {knowledge.id}: {e}")
|
||||
|
||||
result = Files.delete_file_by_id(id, db=db)
|
||||
if result:
|
||||
try:
|
||||
|
||||
@@ -33,7 +33,6 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
replace_imports,
|
||||
get_function_module_from_cache,
|
||||
resolve_valves_schema_options,
|
||||
)
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
@@ -28,7 +29,6 @@ from pydantic import BaseModel, HttpUrl
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -446,7 +446,10 @@ async def get_function_valves_spec_by_id(
|
||||
|
||||
if hasattr(function_module, "Valves"):
|
||||
Valves = function_module.Valves
|
||||
return Valves.schema()
|
||||
schema = Valves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(Valves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -546,7 +549,10 @@ async def get_function_user_valves_spec_by_id(
|
||||
|
||||
if hasattr(function_module, "UserValves"):
|
||||
UserValves = function_module.UserValves
|
||||
return UserValves.schema()
|
||||
schema = UserValves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(UserValves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -7,6 +7,7 @@ from open_webui.models.users import Users, UserInfoResponse
|
||||
from open_webui.models.groups import (
|
||||
Groups,
|
||||
GroupForm,
|
||||
GroupInfoResponse,
|
||||
GroupUpdateForm,
|
||||
GroupResponse,
|
||||
UserIdsForm,
|
||||
@@ -21,7 +22,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -104,6 +104,23 @@ async def get_group_by_id(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/id/{id}/info", response_model=Optional[GroupInfoResponse])
|
||||
async def get_group_info_by_id(
|
||||
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
group = Groups.get_group_by_id(id, db=db)
|
||||
if group:
|
||||
return GroupInfoResponse(
|
||||
**group.model_dump(),
|
||||
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ExportGroupById
|
||||
############################
|
||||
|
||||
@@ -14,8 +14,13 @@ import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN,
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
@@ -198,14 +203,13 @@ async def update_config(
|
||||
|
||||
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE
|
||||
set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
|
||||
if (
|
||||
form_data.IMAGE_SIZE == "auto"
|
||||
and not form_data.IMAGE_GENERATION_MODEL.startswith("gpt-image")
|
||||
if form_data.IMAGE_SIZE == "auto" and not re.match(
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=ERROR_MESSAGES.INCORRECT_FORMAT(
|
||||
" (auto is only allowed with gpt-image models)."
|
||||
f" (auto is only allowed with models matching {IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN})."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -609,8 +613,9 @@ async def image_generations(
|
||||
),
|
||||
**(
|
||||
{}
|
||||
if request.app.state.config.IMAGE_GENERATION_MODEL.startswith(
|
||||
"gpt-image"
|
||||
if re.match(
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL,
|
||||
)
|
||||
else {"response_format": "b64_json"}
|
||||
),
|
||||
@@ -844,6 +849,7 @@ class EditImageForm(BaseModel):
|
||||
size: Optional[str] = None
|
||||
n: Optional[int] = None
|
||||
negative_prompt: Optional[str] = None
|
||||
background: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/edit")
|
||||
@@ -881,6 +887,8 @@ async def image_edits(
|
||||
return data
|
||||
|
||||
if data.startswith("http://") or data.startswith("https://"):
|
||||
# Validate URL to prevent SSRF attacks against local/private networks
|
||||
validate_url(data)
|
||||
r = await asyncio.to_thread(requests.get, data)
|
||||
r.raise_for_status()
|
||||
|
||||
@@ -910,7 +918,10 @@ async def image_edits(
|
||||
if isinstance(form_data.image, str):
|
||||
form_data.image = await load_url_image(form_data.image)
|
||||
elif isinstance(form_data.image, list):
|
||||
form_data.image = [await load_url_image(img) for img in form_data.image]
|
||||
# Load all images in parallel for better performance
|
||||
form_data.image = list(
|
||||
await asyncio.gather(*[load_url_image(img) for img in form_data.image])
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
|
||||
|
||||
@@ -943,9 +954,15 @@ async def image_edits(
|
||||
"prompt": form_data.prompt,
|
||||
**({"n": form_data.n} if form_data.n else {}),
|
||||
**({"size": size} if size else {}),
|
||||
**(
|
||||
{"background": form_data.background} if form_data.background else {}
|
||||
),
|
||||
**(
|
||||
{}
|
||||
if request.app.state.config.IMAGE_EDIT_MODEL.startswith("gpt-image")
|
||||
if re.match(
|
||||
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
|
||||
request.app.state.config.IMAGE_EDIT_MODEL,
|
||||
)
|
||||
else {"response_format": "b64_json"}
|
||||
),
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@ from open_webui.storage.provider import Storage
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_verified_user, get_admin_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.models.models import Models, ModelForm
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -115,8 +115,10 @@ async def get_knowledge_bases(
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
||||
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
if groups:
|
||||
filter["group_ids"] = [group.id for group in groups]
|
||||
|
||||
@@ -126,6 +128,17 @@ async def get_knowledge_bases(
|
||||
user.id, filter=filter, skip=skip, limit=limit, db=db
|
||||
)
|
||||
|
||||
# Batch-fetch writable knowledge IDs in a single query instead of N has_access calls
|
||||
knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items]
|
||||
writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_ids=knowledge_base_ids,
|
||||
permission="write",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return KnowledgeAccessListResponse(
|
||||
items=[
|
||||
KnowledgeAccessResponse(
|
||||
@@ -133,9 +146,7 @@ async def get_knowledge_bases(
|
||||
write_access=(
|
||||
user.id == knowledge_base.user_id
|
||||
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or has_access(
|
||||
user.id, "write", knowledge_base.access_control, db=db
|
||||
)
|
||||
or knowledge_base.id in writable_knowledge_base_ids
|
||||
),
|
||||
)
|
||||
for knowledge_base in result.items
|
||||
@@ -162,8 +173,10 @@ async def search_knowledge_bases(
|
||||
if view_option:
|
||||
filter["view_option"] = view_option
|
||||
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
||||
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
if groups:
|
||||
filter["group_ids"] = [group.id for group in groups]
|
||||
|
||||
@@ -173,6 +186,17 @@ async def search_knowledge_bases(
|
||||
user.id, filter=filter, skip=skip, limit=limit, db=db
|
||||
)
|
||||
|
||||
# Batch-fetch writable knowledge IDs in a single query instead of N has_access calls
|
||||
knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items]
|
||||
writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_ids=knowledge_base_ids,
|
||||
permission="write",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return KnowledgeAccessListResponse(
|
||||
items=[
|
||||
KnowledgeAccessResponse(
|
||||
@@ -180,9 +204,7 @@ async def search_knowledge_bases(
|
||||
write_access=(
|
||||
user.id == knowledge_base.user_id
|
||||
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or has_access(
|
||||
user.id, "write", knowledge_base.access_control, db=db
|
||||
)
|
||||
or knowledge_base.id in writable_knowledge_base_ids
|
||||
),
|
||||
)
|
||||
for knowledge_base in result.items
|
||||
@@ -227,10 +249,13 @@ async def create_new_knowledge(
|
||||
request: Request,
|
||||
form_data: KnowledgeForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (has_permission, insert_new_knowledge) manage their own sessions.
|
||||
# This prevents holding a connection during embed_knowledge_base_metadata()
|
||||
# which makes external embedding API calls (1-5+ seconds).
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -240,17 +265,16 @@ async def create_new_knowledge(
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
form_data.access_grants = []
|
||||
|
||||
knowledge = Knowledges.insert_new_knowledge(user.id, form_data, db=db)
|
||||
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
|
||||
|
||||
if knowledge:
|
||||
# Embed knowledge base for semantic search
|
||||
@@ -345,10 +369,15 @@ async def reindex_knowledge_files(
|
||||
async def reindex_knowledge_base_metadata_embeddings(
|
||||
request: Request,
|
||||
user=Depends(get_admin_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Batch embed all existing knowledge bases. Admin only."""
|
||||
knowledge_bases = Knowledges.get_knowledge_bases(db=db)
|
||||
"""Batch embed all existing knowledge bases. Admin only.
|
||||
|
||||
NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
This endpoint loops through ALL knowledge bases and calls embed_knowledge_base_metadata()
|
||||
for each one, making N external embedding API calls. Holding a session during
|
||||
this entire operation would exhaust the connection pool.
|
||||
"""
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
log.info(f"Reindexing embeddings for {len(knowledge_bases)} knowledge bases")
|
||||
|
||||
success_count = 0
|
||||
@@ -380,7 +409,13 @@ async def get_knowledge_by_id(
|
||||
if (
|
||||
user.role == "admin"
|
||||
or knowledge.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
@@ -388,7 +423,13 @@ async def get_knowledge_by_id(
|
||||
write_access=(
|
||||
user.id == knowledge.user_id
|
||||
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -414,9 +455,12 @@ async def update_knowledge_by_id(
|
||||
id: str,
|
||||
form_data: KnowledgeForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations manage their own short-lived sessions internally.
|
||||
# This prevents holding a connection during embed_knowledge_base_metadata()
|
||||
# which makes external embedding API calls (1-5+ seconds).
|
||||
knowledge = Knowledges.get_knowledge_by_id(id=id)
|
||||
if not knowledge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -425,7 +469,12 @@ async def update_knowledge_by_id(
|
||||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -436,17 +485,16 @@ async def update_knowledge_by_id(
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
form_data.access_grants = []
|
||||
|
||||
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data, db=db)
|
||||
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
|
||||
if knowledge:
|
||||
# Re-embed knowledge base for semantic search
|
||||
await embed_knowledge_base_metadata(
|
||||
@@ -457,7 +505,7 @@ async def update_knowledge_by_id(
|
||||
)
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(),
|
||||
files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
|
||||
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -466,6 +514,73 @@ async def update_knowledge_by_id(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateKnowledgeAccessById
|
||||
############################
|
||||
|
||||
|
||||
class KnowledgeAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/{id}/access/update", response_model=Optional[KnowledgeFilesResponse])
|
||||
async def update_knowledge_access_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: KnowledgeAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
|
||||
if not knowledge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
|
||||
AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**Knowledges.get_knowledge_by_id(id=id, db=db).model_dump(),
|
||||
files=Knowledges.get_file_metadatas_by_id(id, db=db),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetKnowledgeFilesById
|
||||
############################
|
||||
@@ -493,7 +608,13 @@ async def get_knowledge_files_by_id(
|
||||
if not (
|
||||
user.role == "admin"
|
||||
or knowledge.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -546,7 +667,13 @@ def add_file_to_knowledge_by_id(
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -615,7 +742,13 @@ def update_file_from_knowledge_by_id(
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
|
||||
@@ -684,7 +817,13 @@ def remove_file_from_knowledge_by_id(
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -761,7 +900,13 @@ async def delete_knowledge_by_id(
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -793,7 +938,7 @@ async def delete_knowledge_by_id(
|
||||
base_model_id=model.base_model_id,
|
||||
meta=model.meta,
|
||||
params=model.params,
|
||||
access_control=model.access_control,
|
||||
access_grants=model.access_grants,
|
||||
is_active=model.is_active,
|
||||
)
|
||||
Models.update_model_by_id(model.id, model_form, db=db)
|
||||
@@ -830,7 +975,13 @@ async def reset_knowledge_by_id(
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -873,7 +1024,13 @@ async def add_files_to_knowledge_batch(
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -881,17 +1038,19 @@ async def add_files_to_knowledge_batch(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Get files content
|
||||
# Batch-fetch all files to avoid N+1 queries
|
||||
log.info(f"files/batch/add - {len(form_data)} files")
|
||||
files: List[FileModel] = []
|
||||
for form in form_data:
|
||||
file = Files.get_file_by_id(form.file_id, db=db)
|
||||
if not file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File {form.file_id} not found",
|
||||
)
|
||||
files.append(file)
|
||||
file_ids = [form.file_id for form in form_data]
|
||||
files = Files.get_files_by_ids(file_ids, db=db)
|
||||
|
||||
# Verify all requested files were found
|
||||
found_ids = {file.id for file in files}
|
||||
missing_ids = [fid for fid in file_ids if fid not in found_ids]
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File {missing_ids[0]} not found",
|
||||
)
|
||||
|
||||
# Process files
|
||||
try:
|
||||
|
||||
@@ -18,11 +18,6 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ef")
|
||||
async def get_embeddings(request: Request):
|
||||
return {"result": await request.app.state.EMBEDDING_FUNCTION("hello world")}
|
||||
|
||||
|
||||
############################
|
||||
# GetMemories
|
||||
############################
|
||||
@@ -69,8 +64,11 @@ async def add_memory(
|
||||
request: Request,
|
||||
form_data: AddMemoryForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (insert_new_memory) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during EMBEDDING_FUNCTION()
|
||||
# which makes external embedding API calls (1-5+ seconds).
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -85,7 +83,7 @@ async def add_memory(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
memory = Memories.insert_new_memory(user.id, form_data.content, db=db)
|
||||
memory = Memories.insert_new_memory(user.id, form_data.content)
|
||||
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
|
||||
|
||||
@@ -119,8 +117,11 @@ async def query_memory(
|
||||
request: Request,
|
||||
form_data: QueryMemoryForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (get_memories_by_user_id) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during EMBEDDING_FUNCTION()
|
||||
# which makes external embedding API calls (1-5+ seconds).
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -135,7 +136,7 @@ async def query_memory(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
memories = Memories.get_memories_by_user_id(user.id, db=db)
|
||||
memories = Memories.get_memories_by_user_id(user.id)
|
||||
if not memories:
|
||||
raise HTTPException(status_code=404, detail="No memories found for user")
|
||||
|
||||
@@ -157,8 +158,15 @@ async def query_memory(
|
||||
async def reset_memory_from_vector_db(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Reset user's memory vector embeddings.
|
||||
|
||||
CRITICAL: We intentionally do NOT use Depends(get_session) here.
|
||||
This endpoint generates embeddings for ALL user memories in parallel using
|
||||
asyncio.gather(). A user with 100 memories would trigger 100 embedding API
|
||||
calls simultaneously. With a session held, this could block a connection
|
||||
for MINUTES, completely exhausting the connection pool.
|
||||
"""
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -175,7 +183,7 @@ async def reset_memory_from_vector_db(
|
||||
|
||||
VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}")
|
||||
|
||||
memories = Memories.get_memories_by_user_id(user.id, db=db)
|
||||
memories = Memories.get_memories_by_user_id(user.id)
|
||||
|
||||
# Generate vectors in parallel
|
||||
vectors = await asyncio.gather(
|
||||
@@ -252,8 +260,11 @@ async def update_memory_by_id(
|
||||
request: Request,
|
||||
form_data: MemoryUpdateModel,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (update_memory_by_id_and_user_id) manage their own
|
||||
# short-lived sessions. This prevents holding a connection during
|
||||
# EMBEDDING_FUNCTION() which makes external API calls (1-5+ seconds).
|
||||
if not request.app.state.config.ENABLE_MEMORIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -269,7 +280,7 @@ async def update_memory_by_id(
|
||||
)
|
||||
|
||||
memory = Memories.update_memory_by_id_and_user_id(
|
||||
memory_id, user.id, form_data.content, db=db
|
||||
memory_id, user.id, form_data.content
|
||||
)
|
||||
if memory is None:
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
|
||||
@@ -8,13 +8,16 @@ import logging
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import (
|
||||
ModelForm,
|
||||
ModelMeta,
|
||||
ModelModel,
|
||||
ModelParams,
|
||||
ModelResponse,
|
||||
ModelListResponse,
|
||||
ModelAccessListResponse,
|
||||
ModelAccessResponse,
|
||||
Models,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
|
||||
from pydantic import BaseModel
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
@@ -30,7 +33,7 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -83,14 +86,29 @@ async def get_models(
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
# Pre-fetch user group IDs once - used for both filter and write_access check
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
||||
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
if groups:
|
||||
filter["group_ids"] = [group.id for group in groups]
|
||||
|
||||
filter["user_id"] = user.id
|
||||
|
||||
result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db)
|
||||
|
||||
# Batch-fetch writable model IDs in a single query instead of N has_access calls
|
||||
model_ids = [model.id for model in result.items]
|
||||
writable_model_ids = AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_ids=model_ids,
|
||||
permission="write",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return ModelAccessListResponse(
|
||||
items=[
|
||||
ModelAccessResponse(
|
||||
@@ -98,7 +116,7 @@ async def get_models(
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model.user_id
|
||||
or has_access(user.id, "write", model.access_control, db=db)
|
||||
or model.id in writable_model_ids
|
||||
),
|
||||
)
|
||||
for model in result.items
|
||||
@@ -246,12 +264,25 @@ async def import_models(
|
||||
try:
|
||||
data = form_data.models
|
||||
if isinstance(data, list):
|
||||
# Batch-fetch all existing models in one query to avoid N+1
|
||||
model_ids = [
|
||||
model_data.get("id")
|
||||
for model_data in data
|
||||
if model_data.get("id") and is_valid_model_id(model_data.get("id"))
|
||||
]
|
||||
existing_models = {
|
||||
model.id: model
|
||||
for model in (
|
||||
Models.get_models_by_ids(model_ids, db=db) if model_ids else []
|
||||
)
|
||||
}
|
||||
|
||||
for model_data in data:
|
||||
# Here, you can add logic to validate model_data if needed
|
||||
model_id = model_data.get("id")
|
||||
|
||||
if model_id and is_valid_model_id(model_id):
|
||||
existing_model = Models.get_model_by_id(model_id, db=db)
|
||||
existing_model = existing_models.get(model_id)
|
||||
if existing_model:
|
||||
# Update existing model
|
||||
model_data["meta"] = model_data.get("meta", {})
|
||||
@@ -315,14 +346,26 @@ async def get_model_by_id(
|
||||
if (
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or model.user_id == user.id
|
||||
or has_access(user.id, "read", model.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return ModelAccessResponse(
|
||||
**model.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model.user_id
|
||||
or has_access(user.id, "write", model.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -343,10 +386,8 @@ async def get_model_by_id(
|
||||
|
||||
|
||||
@router.get("/model/profile/image")
|
||||
def get_model_profile_image(
|
||||
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
model = Models.get_model_by_id(id, db=db)
|
||||
def get_model_profile_image(id: str, user=Depends(get_verified_user)):
|
||||
model = Models.get_model_by_id(id)
|
||||
|
||||
if model:
|
||||
etag = f'"{model.updated_at}"' if model.updated_at else None
|
||||
@@ -395,7 +436,13 @@ async def toggle_model_by_id(
|
||||
if (
|
||||
user.role == "admin"
|
||||
or model.user_id == user.id
|
||||
or has_access(user.id, "write", model.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
model = Models.toggle_model_by_id(id, db=db)
|
||||
|
||||
@@ -438,7 +485,13 @@ async def update_model_by_id(
|
||||
|
||||
if (
|
||||
model.user_id != user.id
|
||||
and not has_access(user.id, "write", model.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -452,6 +505,91 @@ async def update_model_by_id(
|
||||
return model
|
||||
|
||||
|
||||
############################
|
||||
# UpdateModelAccessById
|
||||
############################
|
||||
|
||||
|
||||
class ModelAccessGrantsForm(BaseModel):
|
||||
id: str
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/model/access/update", response_model=Optional[ModelModel])
|
||||
async def update_model_access_by_id(
|
||||
request: Request,
|
||||
form_data: ModelAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
model = Models.get_model_by_id(form_data.id, db=db)
|
||||
|
||||
# Non-preset models (e.g. direct Ollama/OpenAI models) may not have a DB
|
||||
# entry yet. Create a minimal one so access grants can be stored.
|
||||
if not model:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
model = Models.insert_new_model(
|
||||
ModelForm(
|
||||
id=form_data.id,
|
||||
name=form_data.id,
|
||||
meta=ModelMeta(),
|
||||
params=ModelParams(),
|
||||
),
|
||||
user.id,
|
||||
db=db,
|
||||
)
|
||||
if not model:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error creating model entry"),
|
||||
)
|
||||
|
||||
if (
|
||||
model.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_models",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
|
||||
AccessGrants.set_access_grants(
|
||||
"model", form_data.id, form_data.access_grants, db=db
|
||||
)
|
||||
|
||||
return Models.get_model_by_id(form_data.id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteModelById
|
||||
############################
|
||||
@@ -473,7 +611,13 @@ async def delete_model_by_id(
|
||||
if (
|
||||
user.role != "admin"
|
||||
and model.user_id != user.id
|
||||
and not has_access(user.id, "write", model.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -27,7 +27,8 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -200,8 +201,12 @@ async def get_note_by_id(
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and (
|
||||
not has_access(
|
||||
user.id, type="read", access_control=note.access_control, db=db
|
||||
not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
):
|
||||
@@ -212,13 +217,14 @@ async def get_note_by_id(
|
||||
write_access = (
|
||||
user.role == "admin"
|
||||
or (user.id == note.user_id)
|
||||
or has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=note.access_control,
|
||||
strict=False,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
or has_public_read_access_grant(note.access_grants)
|
||||
)
|
||||
|
||||
return NoteResponse(**note.model_dump(), write_access=write_access)
|
||||
@@ -253,8 +259,12 @@ async def update_note_by_id(
|
||||
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and not has_access(
|
||||
user.id, type="write", access_control=note.access_control, db=db
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -264,7 +274,7 @@ async def update_note_by_id(
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_notes",
|
||||
@@ -272,7 +282,7 @@ async def update_note_by_id(
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
form_data.access_grants = []
|
||||
|
||||
try:
|
||||
note = Notes.update_note_by_id(id, form_data, db=db)
|
||||
@@ -290,6 +300,75 @@ async def update_note_by_id(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateNoteAccessById
|
||||
############################
|
||||
|
||||
|
||||
class NoteAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/{id}/access/update", response_model=Optional[NoteModel])
|
||||
async def update_note_access_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: NoteAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_notes",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
|
||||
AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db)
|
||||
|
||||
return Notes.get_note_by_id(id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteNoteById
|
||||
############################
|
||||
@@ -318,8 +397,12 @@ async def delete_note_by_id(
|
||||
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and not has_access(
|
||||
user.id, type="write", access_control=note.access_control, db=db
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -23,6 +23,7 @@ from open_webui.models.users import UserModel
|
||||
|
||||
from open_webui.env import (
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
)
|
||||
|
||||
from fastapi import (
|
||||
@@ -37,15 +38,19 @@ from fastapi import (
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, validator
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from open_webui.internal.db import get_session
|
||||
|
||||
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.misc import (
|
||||
calculate_sha256,
|
||||
cleanup_response,
|
||||
stream_wrapper,
|
||||
)
|
||||
from open_webui.utils.payload import (
|
||||
apply_model_params_to_body_ollama,
|
||||
@@ -53,9 +58,6 @@ from open_webui.utils.payload import (
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
|
||||
from open_webui.config import (
|
||||
UPLOAD_DIR,
|
||||
)
|
||||
@@ -103,16 +105,6 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||
return None
|
||||
|
||||
|
||||
async def cleanup_response(
|
||||
response: Optional[aiohttp.ClientResponse],
|
||||
session: Optional[aiohttp.ClientSession],
|
||||
):
|
||||
if response:
|
||||
response.close()
|
||||
if session:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def send_post_request(
|
||||
url: str,
|
||||
payload: Union[str, bytes],
|
||||
@@ -124,6 +116,7 @@ async def send_post_request(
|
||||
):
|
||||
|
||||
r = None
|
||||
streaming = False
|
||||
try:
|
||||
session = aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
@@ -137,7 +130,7 @@ async def send_post_request(
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
if metadata and metadata.get("chat_id"):
|
||||
headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
|
||||
|
||||
r = await session.post(
|
||||
url,
|
||||
@@ -168,13 +161,11 @@ async def send_post_request(
|
||||
if content_type:
|
||||
response_headers["Content-Type"] = content_type
|
||||
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
r.content,
|
||||
stream_wrapper(r, session),
|
||||
status_code=r.status,
|
||||
headers=response_headers,
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
res = await r.json()
|
||||
@@ -190,7 +181,7 @@ async def send_post_request(
|
||||
detail=detail if e else "Open WebUI: Server Connection Error",
|
||||
)
|
||||
finally:
|
||||
if not stream:
|
||||
if not streaming:
|
||||
await cleanup_response(r, session)
|
||||
|
||||
|
||||
@@ -426,13 +417,30 @@ async def get_all_models(request: Request, user: UserModel = None):
|
||||
|
||||
async def get_filtered_models(models, user, db=None):
|
||||
# Filter models based on user access control
|
||||
model_ids = [model["model"] for model in models.get("models", [])]
|
||||
model_infos = {
|
||||
model_info.id: model_info
|
||||
for model_info in Models.get_models_by_ids(model_ids, db=db)
|
||||
}
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
|
||||
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
|
||||
accessible_model_ids = AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_ids=list(model_infos.keys()),
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
filtered_models = []
|
||||
for model in models.get("models", []):
|
||||
model_info = Models.get_model_by_id(model["model"], db=db)
|
||||
model_info = model_infos.get(model["model"])
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or has_access(
|
||||
user.id, type="read", access_control=model_info.access_control, db=db
|
||||
):
|
||||
if user.id == model_info.user_id or model_info.id in accessible_model_ids:
|
||||
filtered_models.append(model)
|
||||
return filtered_models
|
||||
|
||||
@@ -442,6 +450,9 @@ async def get_filtered_models(models, user, db=None):
|
||||
async def get_ollama_tags(
|
||||
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
models = []
|
||||
|
||||
if url_idx is None:
|
||||
@@ -650,10 +661,6 @@ async def unload_model(
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
# Canonicalize model name (if not supplied with version)
|
||||
if ":" not in model_name:
|
||||
model_name = f"{model_name}:latest"
|
||||
|
||||
if model_name not in models:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name)
|
||||
@@ -706,6 +713,9 @@ async def pull_model(
|
||||
url_idx: int = 0,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
@@ -737,6 +747,9 @@ async def push_model(
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
@@ -776,6 +789,9 @@ async def create_model(
|
||||
url_idx: int = 0,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
log.debug(f"form_data: {form_data}")
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
|
||||
@@ -800,6 +816,9 @@ async def copy_model(
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
@@ -860,6 +879,9 @@ async def delete_model(
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
@@ -922,6 +944,9 @@ async def delete_model(
|
||||
async def show_model_info(
|
||||
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
@@ -994,16 +1019,19 @@ async def embed(
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
log.info(f"generate_ollama_batch_embeddings {form_data}")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
model = form_data.model
|
||||
|
||||
if ":" not in model:
|
||||
model = f"{model}:latest"
|
||||
# Check if model is already in app state cache to avoid expensive get_all_models() call
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
if not models or model not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
if model in models:
|
||||
url_idx = random.choice(models[model]["urls"])
|
||||
@@ -1076,16 +1104,19 @@ async def embeddings(
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
log.info(f"generate_ollama_embeddings {form_data}")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
model = form_data.model
|
||||
|
||||
if ":" not in model:
|
||||
model = f"{model}:latest"
|
||||
# Check if model is already in app state cache to avoid expensive get_all_models() call
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
if not models or model not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
if model in models:
|
||||
url_idx = random.choice(models[model]["urls"])
|
||||
@@ -1166,15 +1197,14 @@ async def generate_completion(
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
||||
model = form_data.model
|
||||
|
||||
if ":" not in model:
|
||||
model = f"{model}:latest"
|
||||
|
||||
if model in models:
|
||||
url_idx = random.choice(models[model]["urls"])
|
||||
else:
|
||||
@@ -1257,8 +1287,14 @@ async def generate_chat_completion(
|
||||
user=Depends(get_verified_user),
|
||||
bypass_filter: Optional[bool] = False,
|
||||
bypass_system_prompt: bool = False,
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
# NOTE: We intentionally do NOT use Depends(get_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),
|
||||
# which would exhaust the connection pool under concurrent load.
|
||||
if BYPASS_MODEL_ACCESS_CONTROL:
|
||||
bypass_filter = True
|
||||
|
||||
@@ -1279,7 +1315,7 @@ async def generate_chat_completion(
|
||||
del payload["metadata"]
|
||||
|
||||
model_id = payload["model"]
|
||||
model_info = Models.get_model_by_id(model_id, db=db)
|
||||
model_info = Models.get_model_by_id(model_id)
|
||||
|
||||
if model_info:
|
||||
if model_info.base_model_id:
|
||||
@@ -1301,13 +1337,17 @@ async def generate_chat_completion(
|
||||
|
||||
# Check if user has access to the model
|
||||
if not bypass_filter and user.role == "user":
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id)
|
||||
}
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
db=db,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -1321,9 +1361,6 @@ async def generate_chat_completion(
|
||||
detail="Model not found",
|
||||
)
|
||||
|
||||
if ":" not in payload["model"]:
|
||||
payload["model"] = f"{payload['model']}:latest"
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
@@ -1379,8 +1416,11 @@ async def generate_openai_completion(
|
||||
form_data: dict,
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_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),
|
||||
# which would exhaust the connection pool under concurrent load.
|
||||
metadata = form_data.pop("metadata", None)
|
||||
|
||||
try:
|
||||
@@ -1397,10 +1437,7 @@ async def generate_openai_completion(
|
||||
del payload["metadata"]
|
||||
|
||||
model_id = form_data.model
|
||||
if ":" not in model_id:
|
||||
model_id = f"{model_id}:latest"
|
||||
|
||||
model_info = Models.get_model_by_id(model_id, db=db)
|
||||
model_info = Models.get_model_by_id(model_id)
|
||||
if model_info:
|
||||
if model_info.base_model_id:
|
||||
payload["model"] = model_info.base_model_id
|
||||
@@ -1411,13 +1448,17 @@ async def generate_openai_completion(
|
||||
|
||||
# Check if user has access to the model
|
||||
if user.role == "user":
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id)
|
||||
}
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
db=db,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -1431,9 +1472,6 @@ async def generate_openai_completion(
|
||||
detail="Model not found",
|
||||
)
|
||||
|
||||
if ":" not in payload["model"]:
|
||||
payload["model"] = f"{payload['model']}:latest"
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
@@ -1462,8 +1500,11 @@ async def generate_openai_chat_completion(
|
||||
form_data: dict,
|
||||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_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),
|
||||
# which would exhaust the connection pool under concurrent load.
|
||||
metadata = form_data.pop("metadata", None)
|
||||
|
||||
try:
|
||||
@@ -1480,10 +1521,7 @@ async def generate_openai_chat_completion(
|
||||
del payload["metadata"]
|
||||
|
||||
model_id = completion_form.model
|
||||
if ":" not in model_id:
|
||||
model_id = f"{model_id}:latest"
|
||||
|
||||
model_info = Models.get_model_by_id(model_id, db=db)
|
||||
model_info = Models.get_model_by_id(model_id)
|
||||
if model_info:
|
||||
if model_info.base_model_id:
|
||||
payload["model"] = model_info.base_model_id
|
||||
@@ -1498,13 +1536,17 @@ async def generate_openai_chat_completion(
|
||||
|
||||
# Check if user has access to the model
|
||||
if user.role == "user":
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id)
|
||||
}
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
db=db,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -1518,9 +1560,6 @@ async def generate_openai_chat_completion(
|
||||
detail="Model not found",
|
||||
)
|
||||
|
||||
if ":" not in payload["model"]:
|
||||
payload["model"] = f"{payload['model']}:latest"
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
@@ -1598,15 +1637,32 @@ async def get_openai_models(
|
||||
|
||||
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
# Filter models based on user access control
|
||||
model_ids = [model["id"] for model in models]
|
||||
model_infos = {
|
||||
model_info.id: model_info
|
||||
for model_info in Models.get_models_by_ids(model_ids, db=db)
|
||||
}
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
|
||||
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
|
||||
accessible_model_ids = AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_ids=list(model_infos.keys()),
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
filtered_models = []
|
||||
for model in models:
|
||||
model_info = Models.get_model_by_id(model["id"], db=db)
|
||||
model_info = model_infos.get(model["id"])
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
db=db,
|
||||
if (
|
||||
user.id == model_info.user_id
|
||||
or model_info.id in accessible_model_ids
|
||||
):
|
||||
filtered_models.append(model)
|
||||
models = filtered_models
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
@@ -17,13 +18,15 @@ from fastapi.responses import (
|
||||
JSONResponse,
|
||||
PlainTextResponse,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from starlette.background import BackgroundTask
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from open_webui.internal.db import get_session
|
||||
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
)
|
||||
@@ -33,6 +36,7 @@ from open_webui.env import (
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
)
|
||||
from open_webui.models.users import UserModel
|
||||
@@ -45,15 +49,15 @@ from open_webui.utils.payload import (
|
||||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.misc import (
|
||||
cleanup_response,
|
||||
convert_logit_bias_input_to_json,
|
||||
stream_chunks_handler,
|
||||
stream_wrapper,
|
||||
)
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -87,16 +91,6 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||
return None
|
||||
|
||||
|
||||
async def cleanup_response(
|
||||
response: Optional[aiohttp.ClientResponse],
|
||||
session: Optional[aiohttp.ClientSession],
|
||||
):
|
||||
if response:
|
||||
response.close()
|
||||
if session:
|
||||
await session.close()
|
||||
|
||||
|
||||
def openai_reasoning_model_handler(payload):
|
||||
"""
|
||||
Handle reasoning model specific parameters
|
||||
@@ -142,7 +136,7 @@ async def get_headers_and_cookies(
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
if metadata and metadata.get("chat_id"):
|
||||
headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
|
||||
|
||||
token = None
|
||||
auth_type = config.get("auth_type")
|
||||
@@ -347,37 +341,41 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
return []
|
||||
|
||||
# Cache config values locally to avoid repeated Redis lookups.
|
||||
# Each access to request.app.state.config.<KEY> triggers a Redis GET;
|
||||
# caching here avoids hundreds of redundant round-trips.
|
||||
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
|
||||
api_keys = list(request.app.state.config.OPENAI_API_KEYS)
|
||||
api_configs = request.app.state.config.OPENAI_API_CONFIGS
|
||||
|
||||
# Check if API KEYS length is same than API URLS length
|
||||
num_urls = len(request.app.state.config.OPENAI_API_BASE_URLS)
|
||||
num_keys = len(request.app.state.config.OPENAI_API_KEYS)
|
||||
num_urls = len(api_base_urls)
|
||||
num_keys = len(api_keys)
|
||||
|
||||
if num_keys != num_urls:
|
||||
# if there are more keys than urls, remove the extra keys
|
||||
if num_keys > num_urls:
|
||||
new_keys = request.app.state.config.OPENAI_API_KEYS[:num_urls]
|
||||
request.app.state.config.OPENAI_API_KEYS = new_keys
|
||||
api_keys = api_keys[:num_urls]
|
||||
request.app.state.config.OPENAI_API_KEYS = api_keys
|
||||
# if there are more urls than keys, add empty keys
|
||||
else:
|
||||
request.app.state.config.OPENAI_API_KEYS += [""] * (num_urls - num_keys)
|
||||
api_keys += [""] * (num_urls - num_keys)
|
||||
request.app.state.config.OPENAI_API_KEYS = api_keys
|
||||
|
||||
request_tasks = []
|
||||
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
|
||||
if (str(idx) not in request.app.state.config.OPENAI_API_CONFIGS) and (
|
||||
url not in request.app.state.config.OPENAI_API_CONFIGS # Legacy support
|
||||
):
|
||||
for idx, url in enumerate(api_base_urls):
|
||||
if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support
|
||||
request_tasks.append(
|
||||
send_get_request(
|
||||
f"{url}/models",
|
||||
request.app.state.config.OPENAI_API_KEYS[idx],
|
||||
api_keys[idx],
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
else:
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
api_config = api_configs.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
api_configs.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
enable = api_config.get("enable", True)
|
||||
@@ -388,7 +386,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
request_tasks.append(
|
||||
send_get_request(
|
||||
f"{url}/models",
|
||||
request.app.state.config.OPENAI_API_KEYS[idx],
|
||||
api_keys[idx],
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
@@ -417,12 +415,10 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
|
||||
for idx, response in enumerate(responses):
|
||||
if response:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
url = api_base_urls[idx]
|
||||
api_config = api_configs.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
api_configs.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
connection_type = api_config.get("connection_type", "external")
|
||||
@@ -458,13 +454,30 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
|
||||
async def get_filtered_models(models, user, db=None):
|
||||
# Filter models based on user access control
|
||||
model_ids = [model["id"] for model in models.get("data", [])]
|
||||
model_infos = {
|
||||
model_info.id: model_info
|
||||
for model_info in Models.get_models_by_ids(model_ids, db=db)
|
||||
}
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
|
||||
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
|
||||
accessible_model_ids = AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_ids=list(model_infos.keys()),
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
filtered_models = []
|
||||
for model in models.get("data", []):
|
||||
model_info = Models.get_model_by_id(model["id"], db=db)
|
||||
model_info = model_infos.get(model["id"])
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or has_access(
|
||||
user.id, type="read", access_control=model_info.access_control, db=db
|
||||
):
|
||||
if user.id == model_info.user_id or model_info.id in accessible_model_ids:
|
||||
filtered_models.append(model)
|
||||
return filtered_models
|
||||
|
||||
@@ -479,6 +492,10 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
return {"data": []}
|
||||
|
||||
# Cache config value locally to avoid repeated Redis lookups inside
|
||||
# the nested loop in get_merged_models (one GET per model otherwise).
|
||||
api_base_urls = request.app.state.config.OPENAI_API_BASE_URLS
|
||||
|
||||
responses = await get_all_models_responses(request, user=user)
|
||||
|
||||
def extract_data(response):
|
||||
@@ -512,10 +529,10 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
for model in model_list:
|
||||
model_id = model.get("id") or model.get("name")
|
||||
|
||||
if (
|
||||
"api.openai.com"
|
||||
in request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
and not is_supported_openai_models(model_id)
|
||||
base_url = api_base_urls[idx]
|
||||
hostname = urlparse(base_url).hostname if base_url else None
|
||||
if hostname == "api.openai.com" and not is_supported_openai_models(
|
||||
model_id
|
||||
):
|
||||
# Skip unwanted OpenAI models
|
||||
continue
|
||||
@@ -544,6 +561,9 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
async def get_models(
|
||||
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
raise HTTPException(status_code=503, detail="OpenAI API is disabled")
|
||||
|
||||
models = {
|
||||
"data": [],
|
||||
}
|
||||
@@ -584,9 +604,12 @@ async def get_models(
|
||||
if r.status != 200:
|
||||
# Extract response error details if available
|
||||
error_detail = f"HTTP Error: {r.status}"
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
error_detail = f"External Error: {res['error']}"
|
||||
try:
|
||||
res = await r.json()
|
||||
if "error" in res:
|
||||
error_detail = f"External Error: {res['error']}"
|
||||
except Exception:
|
||||
pass
|
||||
raise Exception(error_detail)
|
||||
|
||||
response_data = await r.json()
|
||||
@@ -794,6 +817,115 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
|
||||
return url, payload
|
||||
|
||||
|
||||
def convert_to_responses_payload(payload: dict) -> dict:
|
||||
"""
|
||||
Convert Chat Completions payload to Responses API format.
|
||||
|
||||
Chat Completions: { messages: [{role, content}], ... }
|
||||
Responses API: { input: [{type: "message", role, content: [...]}], instructions: "system" }
|
||||
"""
|
||||
messages = payload.pop("messages", [])
|
||||
|
||||
system_content = ""
|
||||
input_items = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# Check for stored output items (from previous Responses API turn)
|
||||
stored_output = msg.get("output")
|
||||
if stored_output and isinstance(stored_output, list):
|
||||
input_items.extend(stored_output)
|
||||
continue
|
||||
|
||||
if role == "system":
|
||||
if isinstance(content, str):
|
||||
system_content = content
|
||||
elif isinstance(content, list):
|
||||
system_content = "\n".join(
|
||||
p.get("text", "") for p in content if p.get("type") == "text"
|
||||
)
|
||||
continue
|
||||
|
||||
# Convert content format
|
||||
text_type = "output_text" if role == "assistant" else "input_text"
|
||||
|
||||
if isinstance(content, str):
|
||||
content_parts = [{"type": text_type, "text": content}]
|
||||
elif isinstance(content, list):
|
||||
content_parts = []
|
||||
for part in content:
|
||||
if part.get("type") == "text":
|
||||
content_parts.append(
|
||||
{"type": text_type, "text": part.get("text", "")}
|
||||
)
|
||||
elif part.get("type") == "image_url":
|
||||
url_data = part.get("image_url", {})
|
||||
url = (
|
||||
url_data.get("url", "")
|
||||
if isinstance(url_data, dict)
|
||||
else url_data
|
||||
)
|
||||
content_parts.append({"type": "input_image", "image_url": url})
|
||||
else:
|
||||
content_parts = [{"type": text_type, "text": str(content)}]
|
||||
|
||||
input_items.append({"type": "message", "role": role, "content": content_parts})
|
||||
|
||||
responses_payload = {**payload, "input": input_items}
|
||||
|
||||
if system_content:
|
||||
responses_payload["instructions"] = system_content
|
||||
|
||||
if "max_tokens" in responses_payload:
|
||||
responses_payload["max_output_tokens"] = responses_payload.pop("max_tokens")
|
||||
|
||||
# Remove Chat Completions-only parameters not supported by the Responses API
|
||||
for unsupported_key in (
|
||||
"stream_options",
|
||||
"logit_bias",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"stop",
|
||||
):
|
||||
responses_payload.pop(unsupported_key, None)
|
||||
|
||||
# Convert Chat Completions tools format to Responses API format
|
||||
# Chat Completions: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
|
||||
# Responses API: {"type": "function", "name": ..., "description": ..., "parameters": ...}
|
||||
if "tools" in responses_payload and isinstance(responses_payload["tools"], list):
|
||||
converted_tools = []
|
||||
for tool in responses_payload["tools"]:
|
||||
if isinstance(tool, dict) and "function" in tool:
|
||||
func = tool["function"]
|
||||
converted_tool = {"type": tool.get("type", "function")}
|
||||
if isinstance(func, dict):
|
||||
converted_tool["name"] = func.get("name", "")
|
||||
if "description" in func:
|
||||
converted_tool["description"] = func["description"]
|
||||
if "parameters" in func:
|
||||
converted_tool["parameters"] = func["parameters"]
|
||||
if "strict" in func:
|
||||
converted_tool["strict"] = func["strict"]
|
||||
converted_tools.append(converted_tool)
|
||||
else:
|
||||
# Already in correct format or unknown format, pass through
|
||||
converted_tools.append(tool)
|
||||
responses_payload["tools"] = converted_tools
|
||||
|
||||
return responses_payload
|
||||
|
||||
|
||||
def convert_responses_result(response: dict) -> dict:
|
||||
"""
|
||||
Convert non-streaming Responses API result.
|
||||
Just add done flag - pass through raw response, frontend handles output.
|
||||
"""
|
||||
response["done"] = True
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/chat/completions")
|
||||
async def generate_chat_completion(
|
||||
request: Request,
|
||||
@@ -801,8 +933,11 @@ async def generate_chat_completion(
|
||||
user=Depends(get_verified_user),
|
||||
bypass_filter: Optional[bool] = False,
|
||||
bypass_system_prompt: bool = False,
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_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),
|
||||
# which would exhaust the connection pool under concurrent load.
|
||||
if BYPASS_MODEL_ACCESS_CONTROL:
|
||||
bypass_filter = True
|
||||
|
||||
@@ -812,7 +947,7 @@ async def generate_chat_completion(
|
||||
metadata = payload.pop("metadata", None)
|
||||
|
||||
model_id = form_data.get("model")
|
||||
model_info = Models.get_model_by_id(model_id, db=db)
|
||||
model_info = Models.get_model_by_id(model_id)
|
||||
|
||||
# Check model info and override the payload
|
||||
if model_info:
|
||||
@@ -836,13 +971,17 @@ async def generate_chat_completion(
|
||||
|
||||
# Check if user has access to the model
|
||||
if not bypass_filter and user.role == "user":
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id)
|
||||
}
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
db=db,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -856,8 +995,13 @@ async def generate_chat_completion(
|
||||
detail="Model not found",
|
||||
)
|
||||
|
||||
await get_all_models(request, user=user)
|
||||
model = request.app.state.OPENAI_MODELS.get(model_id)
|
||||
# Check if model is already in app state cache to avoid expensive get_all_models() call
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
model = models.get(model_id)
|
||||
|
||||
if model:
|
||||
idx = model["urlIdx"]
|
||||
else:
|
||||
@@ -913,6 +1057,8 @@ async def generate_chat_completion(
|
||||
request, url, key, api_config, metadata, user=user
|
||||
)
|
||||
|
||||
is_responses = api_config.get("api_type") == "responses"
|
||||
|
||||
if api_config.get("azure", False):
|
||||
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||
request_url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||
@@ -923,9 +1069,18 @@ async def generate_chat_completion(
|
||||
headers["api-key"] = key
|
||||
|
||||
headers["api-version"] = api_version
|
||||
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
||||
|
||||
if is_responses:
|
||||
payload = convert_to_responses_payload(payload)
|
||||
request_url = f"{request_url}/responses?api-version={api_version}"
|
||||
else:
|
||||
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
||||
else:
|
||||
request_url = f"{url}/chat/completions"
|
||||
if is_responses:
|
||||
payload = convert_to_responses_payload(payload)
|
||||
request_url = f"{url}/responses"
|
||||
else:
|
||||
request_url = f"{url}/chat/completions"
|
||||
|
||||
payload = json.dumps(payload)
|
||||
|
||||
@@ -952,12 +1107,9 @@ async def generate_chat_completion(
|
||||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_chunks_handler(r.content),
|
||||
stream_wrapper(r, session, stream_chunks_handler),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -972,6 +1124,10 @@ async def generate_chat_completion(
|
||||
else:
|
||||
return PlainTextResponse(status_code=r.status, content=response)
|
||||
|
||||
# Convert Responses API result to simple format
|
||||
if is_responses and isinstance(response, dict):
|
||||
response = convert_responses_result(response)
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -1001,9 +1157,12 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||
# Prepare payload/body
|
||||
body = json.dumps(form_data)
|
||||
# Find correct backend url/key based on model
|
||||
await get_all_models(request, user=user)
|
||||
model_id = form_data.get("model")
|
||||
# Check if model is already in app state cache to avoid expensive get_all_models() call
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if model_id in models:
|
||||
idx = models[model_id]["urlIdx"]
|
||||
|
||||
@@ -1034,12 +1193,9 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
r.content,
|
||||
stream_wrapper(r, session),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -1067,6 +1223,129 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||
await cleanup_response(r, session)
|
||||
|
||||
|
||||
class ResponsesForm(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
model: str
|
||||
input: Optional[list | str] = None
|
||||
instructions: Optional[str] = None
|
||||
stream: Optional[bool] = None
|
||||
temperature: Optional[float] = None
|
||||
max_output_tokens: Optional[int] = None
|
||||
top_p: Optional[float] = None
|
||||
tools: Optional[list] = None
|
||||
tool_choice: Optional[str | dict] = None
|
||||
text: Optional[dict] = None
|
||||
truncation: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
store: Optional[bool] = None
|
||||
reasoning: Optional[dict] = None
|
||||
previous_response_id: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/responses")
|
||||
async def responses(
|
||||
request: Request,
|
||||
form_data: ResponsesForm,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""
|
||||
Forward requests to the OpenAI Responses API endpoint.
|
||||
Routes to the correct upstream backend based on the model field.
|
||||
"""
|
||||
payload = form_data.model_dump(exclude_none=True)
|
||||
body = json.dumps(payload)
|
||||
|
||||
idx = 0
|
||||
model_id = form_data.model
|
||||
if 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
|
||||
if model_id in models:
|
||||
idx = models[model_id]["urlIdx"]
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
r = None
|
||||
session = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||
|
||||
auth_type = api_config.get("auth_type", "bearer")
|
||||
if auth_type not in ("azure_ad", "microsoft_entra_id"):
|
||||
headers["api-key"] = key
|
||||
|
||||
headers["api-version"] = api_version
|
||||
|
||||
model = payload.get("model", "")
|
||||
request_url = (
|
||||
f"{url}/openai/deployments/{model}/responses?api-version={api_version}"
|
||||
)
|
||||
else:
|
||||
request_url = f"{url}/responses"
|
||||
|
||||
session = aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
)
|
||||
r = await session.request(
|
||||
method="POST",
|
||||
url=request_url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
# Check if response is SSE
|
||||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
stream_wrapper(r, session),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
response_data = await r.json()
|
||||
except Exception:
|
||||
response_data = await r.text()
|
||||
|
||||
if r.status >= 400:
|
||||
if isinstance(response_data, (dict, list)):
|
||||
return JSONResponse(status_code=r.status, content=response_data)
|
||||
else:
|
||||
return PlainTextResponse(
|
||||
status_code=r.status, content=response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail="Open WebUI: Server Connection Error",
|
||||
)
|
||||
finally:
|
||||
if not streaming:
|
||||
await cleanup_response(r, session)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
|
||||
async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
"""
|
||||
@@ -1075,7 +1354,24 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
body = await request.body()
|
||||
|
||||
# Parse JSON body to resolve model-based routing
|
||||
payload = None
|
||||
if body:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
payload = None
|
||||
|
||||
idx = 0
|
||||
model_id = payload.get("model") if isinstance(payload, dict) else None
|
||||
if 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
|
||||
if model_id in models:
|
||||
idx = models[model_id]["urlIdx"]
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
@@ -1126,12 +1422,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
if "text/event-stream" in r.headers.get("Content-Type", ""):
|
||||
streaming = True
|
||||
return StreamingResponse(
|
||||
r.content,
|
||||
stream_wrapper(r, session),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
background=BackgroundTask(
|
||||
cleanup_response, response=r, session=session
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
||||
@@ -13,7 +13,6 @@ import aiohttp
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import FileResponse
|
||||
from typing import Optional
|
||||
@@ -217,7 +216,7 @@ async def upload_pipeline(
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
# Save the uploaded file
|
||||
with open(file_path, "wb") as buffer:
|
||||
@@ -226,16 +225,26 @@ async def upload_pipeline(
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": f}
|
||||
r = requests.post(
|
||||
f"{url}/pipelines/upload",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
files=files,
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {key}"}
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
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",
|
||||
)
|
||||
|
||||
async with session.post(
|
||||
f"{url}/pipelines/upload",
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
@@ -244,10 +253,10 @@ async def upload_pipeline(
|
||||
|
||||
detail = None
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
if r is not None:
|
||||
status_code = r.status_code
|
||||
if response is not None:
|
||||
status_code = response.status
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
@@ -272,21 +281,22 @@ class AddPipelineForm(BaseModel):
|
||||
async def add_pipeline(
|
||||
request: Request, form_data: AddPipelineForm, user=Depends(get_admin_user)
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
urlIdx = form_data.urlIdx
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.post(
|
||||
f"{url}/pipelines/add",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={"url": form_data.url},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
f"{url}/pipelines/add",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={"url": form_data.url},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
@@ -294,16 +304,18 @@ async def add_pipeline(
|
||||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
@@ -317,21 +329,22 @@ class DeletePipelineForm(BaseModel):
|
||||
async def delete_pipeline(
|
||||
request: Request, form_data: DeletePipelineForm, user=Depends(get_admin_user)
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
urlIdx = form_data.urlIdx
|
||||
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.delete(
|
||||
f"{url}/pipelines/delete",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={"id": form_data.id},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.delete(
|
||||
f"{url}/pipelines/delete",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={"id": form_data.id},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
@@ -339,16 +352,18 @@ async def delete_pipeline(
|
||||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
@@ -357,15 +372,19 @@ async def delete_pipeline(
|
||||
async def get_pipelines(
|
||||
request: Request, urlIdx: Optional[int] = None, user=Depends(get_admin_user)
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.get(f"{url}/pipelines", headers={"Authorization": f"Bearer {key}"})
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
f"{url}/pipelines",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
@@ -373,16 +392,18 @@ async def get_pipelines(
|
||||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
@@ -394,17 +415,19 @@ async def get_pipeline_valves(
|
||||
pipeline_id: str,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.get(
|
||||
f"{url}/{pipeline_id}/valves", headers={"Authorization": f"Bearer {key}"}
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
f"{url}/{pipeline_id}/valves",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
@@ -412,16 +435,18 @@ async def get_pipeline_valves(
|
||||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
@@ -433,18 +458,19 @@ async def get_pipeline_valves_spec(
|
||||
pipeline_id: str,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.get(
|
||||
f"{url}/{pipeline_id}/valves/spec",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
f"{url}/{pipeline_id}/valves/spec",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
@@ -452,16 +478,18 @@ async def get_pipeline_valves_spec(
|
||||
log.exception(f"Connection error: {e}")
|
||||
|
||||
detail = None
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
@@ -474,19 +502,20 @@ async def update_pipeline_valves(
|
||||
form_data: dict,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
r = None
|
||||
response = None
|
||||
try:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
|
||||
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
|
||||
|
||||
r = requests.post(
|
||||
f"{url}/{pipeline_id}/valves/update",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={**form_data},
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
f"{url}/{pipeline_id}/valves/update",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
json={**form_data},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
return {**data}
|
||||
except Exception as e:
|
||||
@@ -495,15 +524,17 @@ async def update_pipeline_valves(
|
||||
|
||||
detail = None
|
||||
|
||||
if r is not None:
|
||||
if response is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await response.json()
|
||||
if "detail" in res:
|
||||
detail = res["detail"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND),
|
||||
status_code=(
|
||||
response.status if response is not None else status.HTTP_404_NOT_FOUND
|
||||
),
|
||||
detail=detail if detail else "Pipeline not found",
|
||||
)
|
||||
|
||||
@@ -5,18 +5,41 @@ from open_webui.models.prompts import (
|
||||
PromptForm,
|
||||
PromptUserResponse,
|
||||
PromptAccessResponse,
|
||||
PromptAccessListResponse,
|
||||
PromptModel,
|
||||
Prompts,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.prompt_history import (
|
||||
PromptHistories,
|
||||
PromptHistoryModel,
|
||||
PromptHistoryResponse,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PromptVersionUpdateForm(BaseModel):
|
||||
version_id: str
|
||||
|
||||
|
||||
class PromptMetadataForm(BaseModel):
|
||||
name: str
|
||||
command: str
|
||||
tags: Optional[list[str]] = None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
############################
|
||||
# GetPrompts
|
||||
############################
|
||||
@@ -34,26 +57,88 @@ async def get_prompts(
|
||||
return prompts
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[PromptAccessResponse])
|
||||
async def get_prompt_list(
|
||||
@router.get("/tags", response_model=list[str])
|
||||
async def get_prompt_tags(
|
||||
user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
prompts = Prompts.get_prompts(db=db)
|
||||
return Prompts.get_tags(db=db)
|
||||
else:
|
||||
prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
|
||||
tags = set()
|
||||
for prompt in prompts:
|
||||
if prompt.tags:
|
||||
tags.update(prompt.tags)
|
||||
return sorted(list(tags))
|
||||
|
||||
return [
|
||||
PromptAccessResponse(
|
||||
**prompt.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == prompt.user_id
|
||||
or has_access(user.id, "write", prompt.access_control, db=db)
|
||||
),
|
||||
)
|
||||
for prompt in prompts
|
||||
]
|
||||
|
||||
@router.get("/list", response_model=PromptAccessListResponse)
|
||||
async def get_prompt_list(
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
limit = PAGE_ITEM_COUNT
|
||||
|
||||
page = max(1, page)
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if view_option:
|
||||
filter["view_option"] = view_option
|
||||
if tag:
|
||||
filter["tag"] = tag
|
||||
if order_by:
|
||||
filter["order_by"] = order_by
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
# Pre-fetch user group IDs once - used for both filter and write_access check
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
||||
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
if groups:
|
||||
filter["group_ids"] = [group.id for group in groups]
|
||||
|
||||
filter["user_id"] = user.id
|
||||
|
||||
result = Prompts.search_prompts(
|
||||
user.id, filter=filter, skip=skip, limit=limit, db=db
|
||||
)
|
||||
|
||||
# Batch-fetch writable prompt IDs in a single query instead of N has_access calls
|
||||
prompt_ids = [prompt.id for prompt in result.items]
|
||||
writable_prompt_ids = AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_ids=prompt_ids,
|
||||
permission="write",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return PromptAccessListResponse(
|
||||
items=[
|
||||
PromptAccessResponse(
|
||||
**prompt.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == prompt.user_id
|
||||
or prompt.id in writable_prompt_ids
|
||||
),
|
||||
)
|
||||
for prompt in result.items
|
||||
],
|
||||
total=result.total,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -112,52 +197,115 @@ async def create_new_prompt(
|
||||
async def get_prompt_by_command(
|
||||
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
|
||||
prompt = Prompts.get_prompt_by_command(command, db=db)
|
||||
|
||||
if prompt:
|
||||
if (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or has_access(user.id, "read", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return PromptAccessResponse(
|
||||
**prompt.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == prompt.user_id
|
||||
or has_access(user.id, "write", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdatePromptByCommand
|
||||
# GetPromptById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/command/{command}/update", response_model=Optional[PromptModel])
|
||||
async def update_prompt_by_command(
|
||||
command: str,
|
||||
@router.get("/id/{prompt_id}", response_model=Optional[PromptAccessResponse])
|
||||
async def get_prompt_by_id(
|
||||
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if prompt:
|
||||
if (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return PromptAccessResponse(
|
||||
**prompt.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == prompt.user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdatePromptById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/id/{prompt_id}/update", response_model=Optional[PromptModel])
|
||||
async def update_prompt_by_id(
|
||||
prompt_id: str,
|
||||
form_data: PromptForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not has_access(user.id, "write", prompt.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -165,35 +313,56 @@ async def update_prompt_by_command(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
prompt = Prompts.update_prompt_by_command(f"/{command}", form_data, db=db)
|
||||
if prompt:
|
||||
return prompt
|
||||
# Check for command collision if command is being changed
|
||||
if form_data.command != prompt.command:
|
||||
existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
|
||||
if existing_prompt and existing_prompt.id != prompt.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Command '/{form_data.command}' is already in use by another prompt",
|
||||
)
|
||||
|
||||
# Use the ID from the found prompt
|
||||
updated_prompt = Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db)
|
||||
if updated_prompt:
|
||||
return updated_prompt
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeletePromptByCommand
|
||||
# UpdatePromptMetadata
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/command/{command}/delete", response_model=bool)
|
||||
async def delete_prompt_by_command(
|
||||
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
@router.post("/id/{prompt_id}/update/meta", response_model=Optional[PromptModel])
|
||||
async def update_prompt_metadata(
|
||||
prompt_id: str,
|
||||
form_data: PromptMetadataForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
|
||||
"""Update prompt name and command only (no history created)."""
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not has_access(user.id, "write", prompt.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -201,5 +370,348 @@ async def delete_prompt_by_command(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
result = Prompts.delete_prompt_by_command(f"/{command}", db=db)
|
||||
# Check for command collision if command is being changed
|
||||
if form_data.command != prompt.command:
|
||||
existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
|
||||
if existing_prompt and existing_prompt.id != prompt.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Command '/{form_data.command}' is already in use",
|
||||
)
|
||||
|
||||
updated_prompt = Prompts.update_prompt_metadata(
|
||||
prompt.id, form_data.name, form_data.command, form_data.tags, db=db
|
||||
)
|
||||
if updated_prompt:
|
||||
return updated_prompt
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/id/{prompt_id}/update/version", response_model=Optional[PromptModel])
|
||||
async def set_prompt_version(
|
||||
prompt_id: str,
|
||||
form_data: PromptVersionUpdateForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
updated_prompt = Prompts.update_prompt_version(
|
||||
prompt.id, form_data.version_id, db=db
|
||||
)
|
||||
if updated_prompt:
|
||||
return updated_prompt
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdatePromptAccessById
|
||||
############################
|
||||
|
||||
|
||||
class PromptAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/id/{prompt_id}/access/update", response_model=Optional[PromptModel])
|
||||
async def update_prompt_access_by_id(
|
||||
request: Request,
|
||||
prompt_id: str,
|
||||
form_data: PromptAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_prompts",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
|
||||
AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db)
|
||||
|
||||
return Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# DeletePromptById
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/id/{prompt_id}/delete", response_model=bool)
|
||||
async def delete_prompt_by_id(
|
||||
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
result = Prompts.delete_prompt_by_id(prompt.id, db=db)
|
||||
return result
|
||||
|
||||
|
||||
############################
|
||||
# Prompt History Endpoints
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/id/{prompt_id}/history", response_model=list[PromptHistoryResponse])
|
||||
async def get_prompt_history(
|
||||
prompt_id: str,
|
||||
page: int = 0,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get version history for a prompt."""
|
||||
PAGE_SIZE = 20
|
||||
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check read access
|
||||
if not (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
history = PromptHistories.get_history_by_prompt_id(
|
||||
prompt.id, limit=PAGE_SIZE, offset=page * PAGE_SIZE, db=db
|
||||
)
|
||||
return history
|
||||
|
||||
|
||||
@router.get("/id/{prompt_id}/history/{history_id}", response_model=PromptHistoryModel)
|
||||
async def get_prompt_history_entry(
|
||||
prompt_id: str,
|
||||
history_id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get a specific version from history."""
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check read access
|
||||
if not (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
history_entry = PromptHistories.get_history_entry_by_id(history_id, db=db)
|
||||
if not history_entry or history_entry.prompt_id != prompt.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
return history_entry
|
||||
|
||||
|
||||
@router.delete("/id/{prompt_id}/history/{history_id}", response_model=bool)
|
||||
async def delete_prompt_history_entry(
|
||||
prompt_id: str,
|
||||
history_id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Delete a history entry. Cannot delete the active production version."""
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check write access
|
||||
if not (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Cannot delete active production version
|
||||
if prompt.version_id == history_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete the active production version",
|
||||
)
|
||||
|
||||
success = PromptHistories.delete_history_entry(history_id, db=db)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
@router.get("/id/{prompt_id}/history/diff")
|
||||
async def get_prompt_diff(
|
||||
prompt_id: str,
|
||||
from_id: str,
|
||||
to_id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Get diff between two versions."""
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check read access
|
||||
if not (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
diff = PromptHistories.compute_diff(from_id, to_id, db=db)
|
||||
if not diff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="One or both history entries not found",
|
||||
)
|
||||
|
||||
return diff
|
||||
|
||||
@@ -39,7 +39,7 @@ from langchain_core.documents import Document
|
||||
from open_webui.models.files import FileModel, FileUpdateForm, Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.storage.provider import Storage
|
||||
from open_webui.internal.db import get_session
|
||||
from open_webui.internal.db import get_session, get_db
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ from open_webui.retrieval.web.perplexity import search_perplexity
|
||||
from open_webui.retrieval.web.sougou import search_sougou
|
||||
from open_webui.retrieval.web.firecrawl import search_firecrawl
|
||||
from open_webui.retrieval.web.external import search_external
|
||||
from open_webui.retrieval.web.yandex import search_yandex
|
||||
|
||||
from open_webui.retrieval.utils import (
|
||||
get_content_from_url,
|
||||
@@ -109,6 +110,7 @@ from open_webui.config import (
|
||||
from open_webui.env import (
|
||||
DEVICE_TYPE,
|
||||
DOCKER,
|
||||
RAG_EMBEDDING_TIMEOUT,
|
||||
SENTENCE_TRANSFORMERS_BACKEND,
|
||||
SENTENCE_TRANSFORMERS_MODEL_KWARGS,
|
||||
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
|
||||
@@ -468,6 +470,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
# Content extraction settings
|
||||
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
"PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE,
|
||||
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
@@ -577,6 +580,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
|
||||
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
|
||||
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
|
||||
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
|
||||
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
|
||||
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -640,6 +646,9 @@ class WebConfig(BaseModel):
|
||||
YOUTUBE_LOADER_LANGUAGE: Optional[List[str]] = None
|
||||
YOUTUBE_LOADER_PROXY_URL: Optional[str] = None
|
||||
YOUTUBE_LOADER_TRANSLATION: Optional[str] = None
|
||||
YANDEX_WEB_SEARCH_URL: Optional[str] = None
|
||||
YANDEX_WEB_SEARCH_API_KEY: Optional[str] = None
|
||||
YANDEX_WEB_SEARCH_CONFIG: Optional[str] = None
|
||||
|
||||
|
||||
class ConfigForm(BaseModel):
|
||||
@@ -659,6 +668,7 @@ class ConfigForm(BaseModel):
|
||||
# Content extraction settings
|
||||
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
|
||||
PDF_EXTRACT_IMAGES: Optional[bool] = None
|
||||
PDF_LOADER_MODE: Optional[str] = None
|
||||
|
||||
DATALAB_MARKER_API_KEY: Optional[str] = None
|
||||
DATALAB_MARKER_API_BASE_URL: Optional[str] = None
|
||||
@@ -786,6 +796,11 @@ async def update_rag_config(
|
||||
if form_data.PDF_EXTRACT_IMAGES is not None
|
||||
else request.app.state.config.PDF_EXTRACT_IMAGES
|
||||
)
|
||||
request.app.state.config.PDF_LOADER_MODE = (
|
||||
form_data.PDF_LOADER_MODE
|
||||
if form_data.PDF_LOADER_MODE is not None
|
||||
else request.app.state.config.PDF_LOADER_MODE
|
||||
)
|
||||
request.app.state.config.DATALAB_MARKER_API_KEY = (
|
||||
form_data.DATALAB_MARKER_API_KEY
|
||||
if form_data.DATALAB_MARKER_API_KEY is not None
|
||||
@@ -1006,6 +1021,11 @@ async def update_rag_config(
|
||||
if form_data.TEXT_SPLITTER is not None
|
||||
else request.app.state.config.TEXT_SPLITTER
|
||||
)
|
||||
request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = (
|
||||
form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
|
||||
if form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER is not None
|
||||
else request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
|
||||
)
|
||||
request.app.state.config.CHUNK_SIZE = (
|
||||
form_data.CHUNK_SIZE
|
||||
if form_data.CHUNK_SIZE is not None
|
||||
@@ -1023,13 +1043,25 @@ async def update_rag_config(
|
||||
)
|
||||
|
||||
# File upload settings
|
||||
request.app.state.config.FILE_MAX_SIZE = form_data.FILE_MAX_SIZE
|
||||
request.app.state.config.FILE_MAX_COUNT = form_data.FILE_MAX_COUNT
|
||||
request.app.state.config.FILE_MAX_SIZE = (
|
||||
form_data.FILE_MAX_SIZE
|
||||
if form_data.FILE_MAX_SIZE is not None
|
||||
else request.app.state.config.FILE_MAX_SIZE
|
||||
)
|
||||
request.app.state.config.FILE_MAX_COUNT = (
|
||||
form_data.FILE_MAX_COUNT
|
||||
if form_data.FILE_MAX_COUNT is not None
|
||||
else request.app.state.config.FILE_MAX_COUNT
|
||||
)
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
|
||||
form_data.FILE_IMAGE_COMPRESSION_WIDTH
|
||||
if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None
|
||||
else request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH
|
||||
)
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
|
||||
form_data.FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None
|
||||
else request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
)
|
||||
request.app.state.config.ALLOWED_FILE_EXTENSIONS = (
|
||||
form_data.ALLOWED_FILE_EXTENSIONS
|
||||
@@ -1164,6 +1196,15 @@ async def update_rag_config(
|
||||
request.app.state.YOUTUBE_LOADER_TRANSLATION = (
|
||||
form_data.web.YOUTUBE_LOADER_TRANSLATION
|
||||
)
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_URL = (
|
||||
form_data.web.YANDEX_WEB_SEARCH_URL
|
||||
)
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_API_KEY = (
|
||||
form_data.web.YANDEX_WEB_SEARCH_API_KEY
|
||||
)
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = (
|
||||
form_data.web.YANDEX_WEB_SEARCH_CONFIG
|
||||
)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
@@ -1180,6 +1221,7 @@ async def update_rag_config(
|
||||
# Content extraction settings
|
||||
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
"PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE,
|
||||
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
@@ -1287,6 +1329,9 @@ async def update_rag_config(
|
||||
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
|
||||
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
|
||||
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
|
||||
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
|
||||
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
|
||||
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1417,8 +1462,16 @@ def save_docs_to_vector_db(
|
||||
if result is not None and result.ids and len(result.ids) > 0:
|
||||
existing_doc_ids = result.ids[0]
|
||||
if existing_doc_ids:
|
||||
log.info(f"Document with hash {metadata['hash']} already exists")
|
||||
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
|
||||
# Check if the existing document belongs to the same file
|
||||
# If same file_id, this is a re-add/reindex - allow it
|
||||
# If different file_id, this is a duplicate - block it
|
||||
existing_file_id = None
|
||||
if result.metadatas and result.metadatas[0]:
|
||||
existing_file_id = result.metadatas[0][0].get("file_id")
|
||||
|
||||
if existing_file_id != metadata.get("file_id"):
|
||||
log.info(f"Document with hash {metadata['hash']} already exists")
|
||||
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
|
||||
|
||||
if split:
|
||||
if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER:
|
||||
@@ -1538,14 +1591,19 @@ def save_docs_to_vector_db(
|
||||
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
)
|
||||
|
||||
# Run async embedding in sync context
|
||||
embeddings = asyncio.run(
|
||||
# Run async embedding in sync context using the main event loop
|
||||
# This allows the main loop to stay responsive to health checks during long operations
|
||||
embedding_timeout = RAG_EMBEDDING_TIMEOUT
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
embedding_function(
|
||||
list(map(lambda x: x.replace("\n", " "), texts)),
|
||||
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
|
||||
user=user,
|
||||
)
|
||||
),
|
||||
request.app.state.main_loop,
|
||||
)
|
||||
embeddings = future.result(timeout=embedding_timeout)
|
||||
log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items")
|
||||
|
||||
items = [
|
||||
@@ -1586,6 +1644,9 @@ def process_file(
|
||||
):
|
||||
"""
|
||||
Process a file and save its content to the vector database.
|
||||
Process a file and save its content to the vector database.
|
||||
Note: granular session management is used to prevent connection pool exhaustion.
|
||||
The session is committed before external API calls, and updates use a fresh session.
|
||||
"""
|
||||
if user.role == "admin":
|
||||
file = Files.get_file_by_id(form_data.file_id, db=db)
|
||||
@@ -1747,6 +1808,12 @@ def process_file(
|
||||
}
|
||||
else:
|
||||
try:
|
||||
# Commit any pending changes before the slow embedding step.
|
||||
# Note: file is already a Pydantic model (not ORM), so no expunge needed.
|
||||
db.commit()
|
||||
|
||||
# External embedding API takes time (5-60s+).
|
||||
# Subsequent updates use fresh sessions via get_db().
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
docs=docs,
|
||||
@@ -1762,27 +1829,29 @@ def process_file(
|
||||
log.info(f"added {len(docs)} items to collection {collection_name}")
|
||||
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
# Fresh session for the final update.
|
||||
with get_db() as session:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
db=session,
|
||||
)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
db=session,
|
||||
)
|
||||
Files.update_file_hash_by_id(file.id, hash, db=session)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
db=db,
|
||||
)
|
||||
Files.update_file_hash_by_id(file.id, hash, db=db)
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
raise Exception("Error saving document to vector database")
|
||||
except Exception as e:
|
||||
@@ -1790,13 +1859,15 @@ def process_file(
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "failed"},
|
||||
db=db,
|
||||
)
|
||||
# Clear the hash so the file can be re-uploaded after fixing the issue
|
||||
Files.update_file_hash_by_id(file.id, None, db=db)
|
||||
# Fresh session for error status update.
|
||||
with get_db() as session:
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "failed"},
|
||||
db=session,
|
||||
)
|
||||
# Clear the hash so the file can be re-uploaded after fixing the issue
|
||||
Files.update_file_hash_by_id(file.id, None, db=session)
|
||||
|
||||
if "No pandoc was found" in str(e):
|
||||
raise HTTPException(
|
||||
@@ -2206,6 +2277,17 @@ def search_web(
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
user=user,
|
||||
)
|
||||
elif engine == "yandex":
|
||||
return search_yandex(
|
||||
request,
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_URL,
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
user=user,
|
||||
)
|
||||
else:
|
||||
raise Exception("No search engine API key found in environment variables")
|
||||
|
||||
@@ -2626,10 +2708,14 @@ async def process_files_batch(
|
||||
request: Request,
|
||||
form_data: BatchProcessFilesForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
) -> BatchProcessFilesResponse:
|
||||
"""
|
||||
Process a batch of files and save them to the vector database.
|
||||
|
||||
NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
The save_docs_to_vector_db() call makes external embedding API calls which
|
||||
can take 5-60+ seconds for batch operations. Database operations after
|
||||
embedding (Files.update_file_by_id) manage their own short-lived sessions.
|
||||
"""
|
||||
|
||||
collection_name = form_data.collection_name
|
||||
@@ -2689,9 +2775,7 @@ async def process_files_batch(
|
||||
|
||||
# Update all files with collection name
|
||||
for file_update, file_result in zip(file_updates, file_results):
|
||||
Files.update_file_by_id(
|
||||
id=file_result.file_id, form_data=file_update, db=db
|
||||
)
|
||||
Files.update_file_by_id(id=file_result.file_id, form_data=file_update)
|
||||
file_result.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
@@ -2701,7 +2785,9 @@ async def process_files_batch(
|
||||
for file_result in file_results:
|
||||
file_result.status = "failed"
|
||||
file_errors.append(
|
||||
BatchProcessFilesResult(file_id=file_result.file_id, error=str(e))
|
||||
BatchProcessFilesResult(
|
||||
file_id=file_result.file_id, status="failed", error=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
return BatchProcessFilesResponse(results=file_results, errors=file_errors)
|
||||
|
||||
@@ -25,6 +25,9 @@ from open_webui.utils.auth import (
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
from open_webui.config import OAUTH_PROVIDERS
|
||||
from open_webui.env import SCIM_AUTH_PROVIDER
|
||||
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import get_session
|
||||
@@ -300,6 +303,43 @@ def get_scim_auth(
|
||||
)
|
||||
|
||||
|
||||
def get_external_id(user: UserModel) -> Optional[str]:
|
||||
"""Extract externalId from a user's scim data.
|
||||
|
||||
Checks all stored provider entries and returns the first external_id found.
|
||||
"""
|
||||
if not user.scim:
|
||||
return None
|
||||
for provider_data in user.scim.values():
|
||||
if isinstance(provider_data, dict) and "external_id" in provider_data:
|
||||
return provider_data["external_id"]
|
||||
return None
|
||||
|
||||
|
||||
def get_scim_provider() -> str:
|
||||
"""Return the configured SCIM auth provider.
|
||||
|
||||
Requires SCIM_AUTH_PROVIDER env var to be set (e.g. 'microsoft', 'oidc').
|
||||
"""
|
||||
if not SCIM_AUTH_PROVIDER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="SCIM_AUTH_PROVIDER environment variable is required when SCIM is enabled",
|
||||
)
|
||||
return SCIM_AUTH_PROVIDER
|
||||
|
||||
|
||||
def find_user_by_external_id(external_id: str, db=None) -> Optional[UserModel]:
|
||||
"""Find a user by SCIM externalId, falling back to OAuth sub match."""
|
||||
provider = get_scim_provider()
|
||||
user = Users.get_user_by_scim_external_id(provider, external_id, db=db)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Fallback: check if externalId matches an existing OAuth sub (account linking)
|
||||
return Users.get_user_by_oauth_sub(provider, external_id, db=db)
|
||||
|
||||
|
||||
def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
|
||||
"""Convert internal User model to SCIM User"""
|
||||
# Parse display name into name components
|
||||
@@ -321,6 +361,7 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
|
||||
|
||||
return SCIMUser(
|
||||
id=user.id,
|
||||
externalId=get_external_id(user),
|
||||
userName=user.email,
|
||||
name=SCIMName(
|
||||
formatted=user.name,
|
||||
@@ -352,18 +393,17 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
|
||||
def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup:
|
||||
"""Convert internal Group model to SCIM Group"""
|
||||
member_ids = Groups.get_group_user_ids_by_id(group.id, db) or []
|
||||
members = []
|
||||
|
||||
for user_id in member_ids:
|
||||
user = Users.get_user_by_id(user_id, db=db)
|
||||
if user:
|
||||
members.append(
|
||||
SCIMGroupMember(
|
||||
value=user.id,
|
||||
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
|
||||
display=user.name,
|
||||
)
|
||||
)
|
||||
# Batch-fetch all users to avoid N+1 queries
|
||||
users = Users.get_users_by_user_ids(member_ids, db=db) if member_ids else []
|
||||
members = [
|
||||
SCIMGroupMember(
|
||||
value=user.id,
|
||||
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
|
||||
display=user.name,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
return SCIMGroup(
|
||||
id=group.id,
|
||||
@@ -495,13 +535,17 @@ async def get_users(
|
||||
|
||||
# Get users from database
|
||||
if filter:
|
||||
# Simple filter parsing - supports userName eq "email"
|
||||
# In production, you'd want a more robust filter parser
|
||||
# Simple filter parsing - supports userName eq, externalId eq
|
||||
if "userName eq" in filter:
|
||||
email = filter.split('"')[1]
|
||||
user = Users.get_user_by_email(email, db=db)
|
||||
users_list = [user] if user else []
|
||||
total = 1 if user else 0
|
||||
elif "externalId eq" in filter:
|
||||
external_id = filter.split('"')[1]
|
||||
user = find_user_by_external_id(external_id, db=db)
|
||||
users_list = [user] if user else []
|
||||
total = 1 if user else 0
|
||||
else:
|
||||
response = Users.get_users(skip=skip, limit=limit, db=db)
|
||||
users_list = response["users"]
|
||||
@@ -547,17 +591,33 @@ async def create_user(
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""Create SCIM User"""
|
||||
# Check if user already exists
|
||||
existing_user = Users.get_user_by_email(user_data.userName, db=db)
|
||||
# Check for duplicate by externalId
|
||||
if user_data.externalId:
|
||||
existing_user = find_user_by_external_id(user_data.externalId, db=db)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"User with externalId {user_data.externalId} already exists",
|
||||
)
|
||||
|
||||
# Determine primary email (lowercased per RFC 5321)
|
||||
email = user_data.userName
|
||||
for entry in user_data.emails:
|
||||
if entry.primary:
|
||||
email = entry.value
|
||||
break
|
||||
email = email.lower()
|
||||
|
||||
# Check for duplicate by email
|
||||
existing_user = Users.get_user_by_email(email, db=db)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"User with email {user_data.userName} already exists",
|
||||
detail=f"User with email {email} already exists",
|
||||
)
|
||||
|
||||
# Create user
|
||||
user_id = str(uuid.uuid4())
|
||||
email = user_data.emails[0].value if user_data.emails else user_data.userName
|
||||
|
||||
# Parse name if provided
|
||||
name = user_data.displayName
|
||||
@@ -572,7 +632,6 @@ async def create_user(
|
||||
if user_data.photos and len(user_data.photos) > 0:
|
||||
profile_image = user_data.photos[0].value
|
||||
|
||||
# Create user
|
||||
new_user = Users.insert_new_user(
|
||||
id=user_id,
|
||||
name=name,
|
||||
@@ -588,6 +647,12 @@ async def create_user(
|
||||
detail="Failed to create user",
|
||||
)
|
||||
|
||||
# Store externalId in the scim field
|
||||
if user_data.externalId:
|
||||
provider = get_scim_provider()
|
||||
Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db)
|
||||
new_user = Users.get_user_by_id(user_id, db=db)
|
||||
|
||||
return user_to_scim(new_user, request, db=db)
|
||||
|
||||
|
||||
@@ -632,7 +697,6 @@ async def update_user(
|
||||
if user_data.photos and len(user_data.photos) > 0:
|
||||
update_data["profile_image_url"] = user_data.photos[0].value
|
||||
|
||||
# Update user
|
||||
updated_user = Users.update_user_by_id(user_id, update_data, db=db)
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
@@ -640,6 +704,12 @@ async def update_user(
|
||||
detail="Failed to update user",
|
||||
)
|
||||
|
||||
# Update externalId in the scim field
|
||||
if user_data.externalId:
|
||||
provider = get_scim_provider()
|
||||
Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db)
|
||||
updated_user = Users.get_user_by_id(user_id, db=db)
|
||||
|
||||
return user_to_scim(updated_user, request, db=db)
|
||||
|
||||
|
||||
@@ -677,6 +747,9 @@ async def patch_user(
|
||||
update_data["email"] = value
|
||||
elif path == "name.formatted":
|
||||
update_data["name"] = value
|
||||
elif path == "externalId":
|
||||
provider = get_scim_provider()
|
||||
Users.update_user_scim_by_id(user_id, provider, value, db=db)
|
||||
|
||||
# Update user
|
||||
if update_data:
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.models.groups import Groups
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from open_webui.internal.db import get_session
|
||||
from open_webui.models.skills import (
|
||||
SkillForm,
|
||||
SkillModel,
|
||||
SkillResponse,
|
||||
SkillUserResponse,
|
||||
SkillAccessResponse,
|
||||
SkillAccessListResponse,
|
||||
Skills,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
############################
|
||||
# GetSkills
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SkillUserResponse])
|
||||
async def get_skills(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
skills = Skills.get_skills(db=db)
|
||||
else:
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
all_skills = Skills.get_skills(db=db)
|
||||
skills = [
|
||||
skill
|
||||
for skill in all_skills
|
||||
if skill.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
return skills
|
||||
|
||||
|
||||
############################
|
||||
# GetSkillList
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/list", response_model=SkillAccessListResponse)
|
||||
async def get_skill_list(
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
limit = PAGE_ITEM_COUNT
|
||||
|
||||
page = max(1, page)
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
if view_option:
|
||||
filter["view_option"] = view_option
|
||||
|
||||
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
groups = Groups.get_groups_by_member_id(user.id, db=db)
|
||||
if groups:
|
||||
filter["group_ids"] = [group.id for group in groups]
|
||||
|
||||
filter["user_id"] = user.id
|
||||
|
||||
result = Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db)
|
||||
|
||||
return SkillAccessListResponse(
|
||||
items=[
|
||||
SkillAccessResponse(
|
||||
**skill.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == skill.user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
for skill in result.items
|
||||
],
|
||||
total=result.total,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ExportSkills
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/export", response_model=list[SkillModel])
|
||||
async def export_skills(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id,
|
||||
"workspace.skills",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return Skills.get_skills(db=db)
|
||||
else:
|
||||
return Skills.get_skills_by_user_id(user.id, "read", db=db)
|
||||
|
||||
|
||||
############################
|
||||
# CreateNewSkill
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/create", response_model=Optional[SkillResponse])
|
||||
async def create_new_skill(
|
||||
request: Request,
|
||||
form_data: SkillForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "workspace.skills", request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
form_data.id = form_data.id.lower().replace(" ", "-")
|
||||
|
||||
existing = Skills.get_skill_by_id(form_data.id, db=db)
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ID_TAKEN,
|
||||
)
|
||||
|
||||
try:
|
||||
skill = Skills.insert_new_skill(user.id, form_data, db=db)
|
||||
if skill:
|
||||
return skill
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error creating skill"),
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to create skill: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(str(e)),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetSkillById
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/id/{id}", response_model=Optional[SkillAccessResponse])
|
||||
async def get_skill_by_id(
|
||||
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
|
||||
if skill:
|
||||
if (
|
||||
user.role == "admin"
|
||||
or skill.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return SkillAccessResponse(
|
||||
**skill.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == skill.user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateSkillById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/id/{id}/update", response_model=Optional[SkillModel])
|
||||
async def update_skill_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: SkillForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
if not skill:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
skill.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
try:
|
||||
updated = {
|
||||
**form_data.model_dump(exclude={"id"}),
|
||||
}
|
||||
|
||||
skill = Skills.update_skill_by_id(id, updated, db=db)
|
||||
|
||||
if skill:
|
||||
return skill
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error updating skill"),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(str(e)),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateSkillAccessById
|
||||
############################
|
||||
|
||||
|
||||
class SkillAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/id/{id}/access/update", response_model=Optional[SkillModel])
|
||||
async def update_skill_access_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: SkillAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
if not skill:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
skill.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_skills",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
|
||||
AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db)
|
||||
|
||||
return Skills.get_skill_by_id(id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# ToggleSkillById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/id/{id}/toggle", response_model=Optional[SkillModel])
|
||||
async def toggle_skill_by_id(
|
||||
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
if skill:
|
||||
if (
|
||||
user.role == "admin"
|
||||
or skill.user_id == user.id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
skill = Skills.toggle_skill_by_id(id, db=db)
|
||||
|
||||
if skill:
|
||||
return skill
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT("Error toggling skill"),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteSkillById
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/id/{id}/delete", response_model=bool)
|
||||
async def delete_skill_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
skill = Skills.get_skill_by_id(id, db=db)
|
||||
if not skill:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
skill.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="skill",
|
||||
resource_id=skill.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
result = Skills.delete_skill_by_id(id, db=db)
|
||||
return result
|
||||
@@ -36,7 +36,6 @@ from open_webui.config import (
|
||||
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -49,6 +48,21 @@ router = APIRouter()
|
||||
##################################
|
||||
|
||||
|
||||
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 {
|
||||
|
||||
@@ -21,10 +21,12 @@ from open_webui.models.tools import (
|
||||
ToolAccessResponse,
|
||||
Tools,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.utils.plugin import (
|
||||
load_tool_module_by_id,
|
||||
replace_imports,
|
||||
get_tool_module_from_cache,
|
||||
resolve_valves_schema_options,
|
||||
)
|
||||
from open_webui.utils.tools import get_tool_specs
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
@@ -34,7 +36,6 @@ from open_webui.utils.tools import get_tool_servers
|
||||
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -75,12 +76,21 @@ async def get_tools(
|
||||
)
|
||||
|
||||
# OpenAPI Tool Servers
|
||||
server_access_grants = {}
|
||||
for server in await get_tool_servers(request):
|
||||
connection = request.app.state.config.TOOL_SERVER_CONNECTIONS[
|
||||
server.get("idx", 0)
|
||||
]
|
||||
server_config = connection.get("config", {})
|
||||
|
||||
server_id = f"server:{server.get('id')}"
|
||||
server_access_grants[server_id] = server_config.get("access_grants", [])
|
||||
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
"id": f"server:{server.get('id')}",
|
||||
"user_id": f"server:{server.get('id')}",
|
||||
"id": server_id,
|
||||
"user_id": server_id,
|
||||
"name": server.get("openapi", {})
|
||||
.get("info", {})
|
||||
.get("title", "Tool Server"),
|
||||
@@ -89,11 +99,6 @@ async def get_tools(
|
||||
.get("info", {})
|
||||
.get("description", ""),
|
||||
},
|
||||
"access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[
|
||||
server.get("idx", 0)
|
||||
]
|
||||
.get("config", {})
|
||||
.get("access_control", None),
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
@@ -102,7 +107,9 @@ async def get_tools(
|
||||
|
||||
# MCP Tool Servers
|
||||
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
if server.get("type", "openapi") == "mcp":
|
||||
if server.get("type", "openapi") == "mcp" and server.get("config", {}).get(
|
||||
"enable"
|
||||
):
|
||||
server_id = server.get("info", {}).get("id")
|
||||
auth_type = server.get("auth_type", "none")
|
||||
|
||||
@@ -117,20 +124,22 @@ async def get_tools(
|
||||
)
|
||||
)
|
||||
|
||||
server_config = server.get("config", {})
|
||||
|
||||
tool_id = f"server:mcp:{server.get('info', {}).get('id')}"
|
||||
server_access_grants[tool_id] = server_config.get("access_grants", [])
|
||||
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
"id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"id": tool_id,
|
||||
"user_id": tool_id,
|
||||
"name": server.get("info", {}).get("name", "MCP Tool Server"),
|
||||
"meta": {
|
||||
"description": server.get("info", {}).get(
|
||||
"description", ""
|
||||
),
|
||||
},
|
||||
"access_control": server.get("config", {}).get(
|
||||
"access_control", None
|
||||
),
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
**(
|
||||
@@ -155,7 +164,24 @@ async def get_tools(
|
||||
tool
|
||||
for tool in tools
|
||||
if tool.user_id == user.id
|
||||
or has_access(user.id, "read", tool.access_control, user_group_ids, db=db)
|
||||
or (
|
||||
has_access(
|
||||
user.id,
|
||||
"read",
|
||||
server_access_grants.get(str(tool.id), []),
|
||||
user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
if str(tool.id).startswith("server:")
|
||||
else AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tool.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
]
|
||||
return tools
|
||||
|
||||
@@ -180,7 +206,13 @@ async def get_tool_list(
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == tool.user_id
|
||||
or has_access(user.id, "write", tool.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tool.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
for tool in tools
|
||||
@@ -381,14 +413,26 @@ async def get_tools_by_id(
|
||||
if (
|
||||
user.role == "admin"
|
||||
or tools.user_id == user.id
|
||||
or has_access(user.id, "read", tools.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return ToolAccessResponse(
|
||||
**tools.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == tools.user_id
|
||||
or has_access(user.id, "write", tools.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -426,7 +470,13 @@ async def update_tools_by_id(
|
||||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not has_access(user.id, "write", tools.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -467,6 +517,70 @@ async def update_tools_by_id(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateToolAccessById
|
||||
############################
|
||||
|
||||
|
||||
class ToolAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post("/id/{id}/access/update", response_model=Optional[ToolModel])
|
||||
async def update_tool_access_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ToolAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
tools = Tools.get_tool_by_id(id, db=db)
|
||||
if not tools:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_tools",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
|
||||
AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db)
|
||||
|
||||
return Tools.get_tool_by_id(id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteToolsById
|
||||
############################
|
||||
@@ -488,7 +602,13 @@ async def delete_tools_by_id(
|
||||
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not has_access(user.id, "write", tools.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -553,7 +673,10 @@ async def get_tools_valves_spec_by_id(
|
||||
|
||||
if hasattr(tools_module, "Valves"):
|
||||
Valves = tools_module.Valves
|
||||
return Valves.schema()
|
||||
schema = Valves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(Valves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -584,7 +707,13 @@ async def update_tools_valves_by_id(
|
||||
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not has_access(user.id, "write", tools.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
@@ -662,7 +791,10 @@ async def get_tools_user_valves_spec_by_id(
|
||||
|
||||
if hasattr(tools_module, "UserValves"):
|
||||
UserValves = tools_module.UserValves
|
||||
return UserValves.schema()
|
||||
schema = UserValves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(UserValves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -19,7 +19,7 @@ from open_webui.models.users import (
|
||||
UserModel,
|
||||
UserGroupIdsModel,
|
||||
UserGroupIdsListResponse,
|
||||
UserInfoListResponse,
|
||||
UserInfoResponse,
|
||||
UserInfoListResponse,
|
||||
UserRoleUpdateForm,
|
||||
UserStatus,
|
||||
@@ -41,7 +41,6 @@ from open_webui.utils.auth import (
|
||||
)
|
||||
from open_webui.utils.access_control import get_permissions, has_permission
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -173,6 +172,7 @@ class WorkspacePermissions(BaseModel):
|
||||
knowledge: bool = False
|
||||
prompts: bool = False
|
||||
tools: bool = False
|
||||
skills: bool = False
|
||||
models_import: bool = False
|
||||
models_export: bool = False
|
||||
prompts_import: bool = False
|
||||
@@ -190,6 +190,8 @@ class SharingPermissions(BaseModel):
|
||||
public_prompts: bool = False
|
||||
tools: bool = False
|
||||
public_tools: bool = True
|
||||
skills: bool = False
|
||||
public_skills: bool = False
|
||||
notes: bool = False
|
||||
public_notes: bool = True
|
||||
|
||||
@@ -446,7 +448,7 @@ class UserActiveResponse(UserStatus):
|
||||
|
||||
@router.get("/{user_id}", response_model=UserActiveResponse)
|
||||
async def get_user_by_id(
|
||||
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
|
||||
):
|
||||
# Check if user_id is a shared chat
|
||||
# If it is, get the user_id from the chat
|
||||
@@ -478,6 +480,27 @@ async def get_user_by_id(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}/info", response_model=UserInfoResponse)
|
||||
async def get_user_info_by_id(
|
||||
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
user = Users.get_user_by_id(user_id, db=db)
|
||||
if user:
|
||||
groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||
return UserInfoResponse(
|
||||
**{
|
||||
**user.model_dump(),
|
||||
"groups": [{"id": group.id, "name": group.name} for group in groups],
|
||||
"is_active": Users.is_user_active(user_id, db=db),
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}/oauth/sessions")
|
||||
async def get_user_oauth_sessions_by_id(
|
||||
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
|
||||
@@ -498,10 +521,8 @@ async def get_user_oauth_sessions_by_id(
|
||||
|
||||
|
||||
@router.get("/{user_id}/profile/image")
|
||||
async def get_user_profile_image_by_id(
|
||||
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
user = Users.get_user_by_id(user_id, db=db)
|
||||
def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)):
|
||||
user = Users.get_user_by_id(user_id)
|
||||
if user:
|
||||
if user.profile_image_url:
|
||||
# check if it's url or base64
|
||||
|
||||
@@ -15,7 +15,6 @@ from open_webui.utils.pdf_generator import PDFGenerator
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user